gioser

tawasuyu/brahman

Rust 566TOML 214Markdown 67JSON 198093 Definitions

⎇ 7fb2ad3b1ebba21ac0dd997f8e282f67c847888b CHANGELOG.md Markdown · 4858 lines · 228.0 KB History raw

fix(nakui-core): schema_bundle_hash usa bytes reales del schema, no del bundle 7fb2ad3b1 · Sergio ·

1# Changelog
2
3Registro cronológico de cambios sustantivos en el monorepo Brahman. Cada
4entrada lista las acciones concretas tras un commit; para detalles de
5ratio/diff ver `git show <sha>`.
6
7## 2026-05-10
8
9### fix(nakui-core): schema_bundle_hash debe reflejar el contenido real del schema
10Iter 17. Regresión surfaceada por el workspace test
11`verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a
12`verify_log_rejects_seed_after_schema_changes`).
13
14**Bug**: `compute_schema_bundle_hash` operaba sobre los bytes del
15bundle compilado, que `build_schema_bundle` arma como
16`(import "/abs/path/schema.ncl") & ...`. Los bytes del bundle nunca
17cambian cuando se edita el archivo apuntado — sólo cambian si se
18agregan/quitan schemas o se mueve el módulo de path. Resultado: el
19hash quedaba pegado y los seeds firmados bajo schema vN se aceptaban
20silenciosamente como válidos bajo schema vN+1, aunque las invariantes
21hubieran cambiado.
22
23**Fix**: nueva fn `read_schema_files_concat(module_dir, schemas)`
24que lee los bytes de cada schema declarado y los concatena con
25framing `\0NCL:<name>\0` (separador no ambiguo + nombre relativo, no
26absoluto, para que el hash sea estable entre máquinas). Esos bytes
27alimentan `compute_schema_bundle_hash` y `compute_morphism_schema_hash`
28en lugar de los bytes del bundle. El bundle compilado sigue siendo
29imports-style (necesario para que Nickel resuelva paths relativos);
30sólo la fuente del hash cambió.
31
32**Impacto en logs existentes**: como cualquier cambio al insumo del
33hash, los seeds y morphisms versados bajo el bundle hash anterior
34fallarán `SchemaMismatch` al verificarse contra un binario nuevo. No
35hay migración — esto es exactamente el comportamiento que el hash
36busca: re-seed.
37
38Tests: 10/10 en `schema_versioning` (era 9/10 con 1 FAILED).
39
40### feat(yahweh-widget-app-header): promover el header standard de explorers
41Iter 16. Patrón con 4 consumers idénticos: `nakui-explorer`,
42`nouser-explorer`, `minga-explorer`, `brahman-broker-explorer`
43todos declaraban un header `flex_row + flex_grow(label) +
44theme_switcher + bg(panel) + border-bottom + text_size(14) +
45padding(16/12)`. Ahora es 1 línea.
46
47Crate nuevo `crates/modules/ui_engine/widgets/app-header/`
48(`yahweh-widget-app-header`):
49- **Deps**: `gpui`, `yahweh-theme`, `yahweh-widget-theme-switcher`.
50  El switcher se incluye automáticamente.
51- **`pub fn app_header(cx: &mut App, label: impl Into<SharedString>)
52  -> impl IntoElement`**: caso simple con texto plano.
53- **`pub fn app_header_with(cx, label_child: impl IntoElement)`**:
54  variante para cuando el lado izquierdo no es texto plano (icon
55  + text, múltiples spans, etc.).
56- 3 tests `#[gpui::test]`: smoke con string label, con custom
57  child IntoElement, type-check de label con literal/owned/format!.
58
59Migración de los 4 consumers:
60- Cada uno reemplaza un bloque `let header = div().flex().flex_row()...
61  .child(theme_switcher(cx))` (~13 líneas) por
62  `let header = app_header(cx, header_text)` (~1 línea).
63- Cada uno borra dep `yahweh-widget-theme-switcher` (ya no la
64  necesita directo — el `app_header` la incluye internamente).
65- Cada uno reemplaza `use yahweh_widget_theme_switcher::theme_switcher`
66  por `use yahweh_widget_app_header::app_header`.
67
68Total ahorro: ~50 líneas de código UI hardcoded en consumers.
69Cambios visuales en el header (padding, border, text_size) ahora
70viven en un solo lugar.
71
72Tests stack: 3 nuevos en app-header; suites de los 4 consumers
73intactas. Todo verde.
74
75Decisión: el sidebar header del `MetaApp` (que también incluye
76theme_switcher) NO se migra — es un header de sidebar, no de app
77top, y tiene styling distinto (px(12/10/13), sin bg/border-bottom
78porque ya está dentro del panel). Diferente patrón → diferente
79widget si emerge segundo consumer.
80
81### feat(yahweh-widget-stat-card): promover el patrón stat card como widget
82Iter 15. El patrón "tarjeta de dashboard con border-l accent +
83label + valor grande + descripción + listing opcional" tenía 2
84consumers (`minga-explorer` y `brahman-broker-explorer`); ahora vale
85extraer al stack yahweh para reusabilidad y mantenimiento single-place.
86
87Crate nuevo `crates/modules/ui_engine/widgets/stat-card/`
88(`yahweh-widget-stat-card`):
89- **Deps**: `gpui` + `yahweh-widget-card` (compone `card_themed`).
90  Sin theme directo — el caller pasa `text` y `text_dim` ya
91  resueltos del theme.
92- **`pub fn stat_card(cx, label, value, description, accent,
93  text, text_dim, recent_items)`**:
94  - `cx: &App` (acepta `&Context<T>` por deref auto-coerce).
95  - `value: impl Into<SharedString>` — sirve para counts (`"3"`),
96    status text (`"UP"`), o cualquier label corto.
97  - `recent_items: &[String]` — si no vacío, agrega sub-header
98    `"recent (N):"` + una linea por item.
99- 3 tests `#[gpui::test]` con TestAppContext: smoke con/sin
100  recent_items, type-check de `value` con literal/format/owned.
101- Dev-deps: gpui con `test-support` + yahweh-theme para construir
102  el cx con un theme global.
103
104Cambios consumer:
105- **`minga-explorer`**: sustituye su `fn stat_card` local
106  (~60 líneas) por `use yahweh_widget_stat_card::stat_card`.
107  Borra dep `yahweh-widget-card` (ya no se usa directo). Adapta
108  los 3 callsites para pasar `value.to_string()` (el widget
109  acepta `Into<SharedString>`).
110- **`brahman-broker-explorer`**: refactoriza su `fn state_card`
111  para que sea un wrap de `stat_card` con la traducción
112  `ProbeState → (accent, value, description)`. La función queda
113  como helper local porque la mapping del enum es app-specific,
114  pero el rendering pasa por el widget compartido. Borra dep
115  `yahweh-widget-card`.
116
117Tests stack: nuevos 3 del widget. Suites de los 2 consumers
118intactas (4 minga-explorer, 2 broker-explorer). Stack total ~120
119verdes (varía por compilation cache).
120
121Beneficio operativo:
122- Cualquier app nueva que necesite cards de dashboard usa
123  `stat_card(...)` directo; no re-implementa el pattern.
124- Cambios visuales (text sizes, padding, sub-header format)
125  ahora viven en un solo lugar.
126- `value: impl Into<SharedString>` es más expressive que el
127  `usize` rígido del original local.
128
129Pequeña simplificación documentada: el sub-header del listing
130pasa de `"recent (N de TOTAL):"` a `"recent (N):"`. El "TOTAL"
131ya no se calcula porque el widget no lo conoce — el caller que
132quiera mostrarlo lo formatea en el label/value (ej. label `"Nodos
133AST (5 de 247)"`). Acceptable trade-off por la reusabilidad
134genérica.
135
136### feat(brahman-broker-explorer): nueva app probe del broker brahman
137Iter 14. Cierra otro frente: visibilidad del broker brahman (el
138broker handshake que matchea Cards consumer/producer). Hasta ahora
139no había forma de "ver" si el broker estaba up sin invocar otro
140binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3
141estados claros.
142
143Crate nuevo `crates/apps/brahman-broker-explorer/`:
144- **Deps**: `brahman-handshake`, `brahman-sidecar` + el stack
145  yahweh themed (theme + 3 widgets). Consume el mismo
146  `await_provider_blocking` que usa `nouser-explorer`.
147- **`ProbeState` enum** con 4 variants:
148  - `Pending` (estado inicial al boot, antes del primer probe).
149  - `Down { reason }` — connect failed, broker no escucha.
150  - `UpNoProvider { flow }` — broker reachable + sin productor
151    para el flow probado dentro del timeout.
152  - `UpWithProvider { flow, producer_socket }` — broker reachable
153    + matcheó algo, devuelve el socket del provider.
154- **Polling loop** en `cx.spawn` cada 5s; el probe (que es
155  bloqueante porque internamente usa tokio runtime) se ejecuta en
156  `cx.background_executor().spawn(...)` para no congelar el main
157  thread del UI.
158- **Configuración via env**:
159  - `BRAHMAN_INIT_SOCKET` — path del broker (default resuelto por
160    `brahman_handshake::transport`).
161  - `BRAHMAN_BROKER_PROBE_FLOW` — flow del Card observer
162    (default `broker-health`).
163  - `BRAHMAN_BROKER_PROBE_TYPE` — type name (default `ping`).
164- **UI**: header con probe info + theme switcher; banner permanente
165  (Error/Warning/Success/none según estado) debajo del header;
166  stat card con accent color por estado y descripción.
167- 2 tests sanity (default state es Pending; constants coherentes:
168  PROBE_TIMEOUT < POLL_INTERVAL >= 2s).
169
170Smoke run del binario verificado: bootstrap completo OK, panic
171esperado en open_window por falta de display.
172
173Beneficio operativo:
174- Si tenés un broker corriendo en `~/.local/share/brahman/init.sock`,
175  el explorer lo detecta + reporta estado verde con su socket.
176- Si no hay broker, banner rojo + msg claro indicando el path
177  probado.
178- Si hay broker pero ningún Card produce el flow probado, banner
179  amber — útil para distinguir "broker down" de "broker up,
180  no productor del tipo X".
181- Apuntando el flow/type via env, podés monitor productores
182  específicos: ej. `BRAHMAN_BROKER_PROBE_FLOW=monad-list
183  BRAHMAN_BROKER_PROBE_TYPE=json` para ver si nouser está sirviendo.
184
185Apps GUI integradas al stack themed: **5** (nakui-ui, nakui-explorer,
186nouser-explorer, minga-explorer, brahman-broker-explorer).
187
188Limitaciones documentadas:
189- El observer registra una Card temporal en cada probe (cada 5s).
190  Eso ensucia un poco las estadísticas del broker (Cards
191  registradas/desregistradas). No impacta funcionalidad pero
192  inflama el log si el broker tiene observability habilitada.
193- No muestra la **lista global** de Cards registradas en el broker
194  — el protocolo handshake actual no expone esa API. Para eso
195  habría que agregar un endpoint `ListSessions` al broker server.
196- No mantiene un buffer de MatchEvents. Cada probe es independiente.
197  Para timeline de matches, hace falta mantener el Client vivo
198  entre probes — scope futuro.
199
200### feat(yahweh-theme): persistencia de la preferencia de theme entre runs
201Iter 13. El theme switcher ya cambiaba el chrome en runtime, pero
202al cerrar y reabrir la app el theme volvía a Nebula default. Ahora
203el name del theme se persiste en `$XDG_CONFIG_HOME/yahweh/theme`
204(default `~/.config/yahweh/theme`) y se restaura al boot.
205
206Cambios en `yahweh-theme`:
207- **`pub fn config_path() -> Option<PathBuf>`**: resuelve el path
208  XDG. Devuelve `None` si ni `XDG_CONFIG_HOME` ni `HOME` están
209  set (sandbox/CI).
210- **`pub fn load_persisted() -> Option<Theme>`**: lee el archivo,
211  trim, busca el theme por name vía `Theme::by_name`. `None` si
212  el file no existe, lectura falla, o el name no matchea ningún
213  preset (e.g. preset renombrado entre versiones).
214- **`pub fn persist(theme: &Theme) -> io::Result<()>`**: escribe
215  el name al config file. Crea el dir parent si no existe.
216- **`pub fn load_from_path` y `pub fn persist_to_path`**: variantes
217  con path explícito — útiles para tests con tempfile y para apps
218  que quieren un path custom (multi-user, staging, etc.).
219- **`Theme::install_default(cx)` cambia**: antes hardcoded
220  `nebula()`. Ahora intenta `load_persisted()`, fallback a Nebula.
221- **`Theme::set(cx, theme)` cambia**: antes sólo `cx.set_global`.
222  Ahora también `persist(&theme)` antes (best-effort: ignora io
223  errors). El `theme_switcher` widget ya consume `Theme::set`, así
224  que sin cambios en su código el switching ahora persiste.
225
2265 tests nuevos (`persistence_tests`):
227- `persist_then_load_round_trip` — escribir + leer Aurora.
228- `load_from_missing_file_returns_none` — no rebota.
229- `load_from_unknown_name_returns_none` — name desconocido →
230  `None` (degrada a default cuando se usa).
231- `persist_creates_parent_dir_if_missing` — crea
232  `~/.config/yahweh/` si no existe.
233- `config_path_uses_xdg_config_home_when_set` — respeta el env.
234
235Tests stack: ~5 nuevos en yahweh-theme. Todos los downstream
236(nakui-ui, *-explorer) compilan sin tocar nada — la API pública
237de `Theme::install_default` y `Theme::set` no cambió shape.
238Smoke run del binario verificado: bootstrap OK, panic esperado
239sin display.
240
241Beneficio operativo:
242- Usuario abre `nakui-ui`, cicla a Aurora con el switcher, cierra
243  app. Próxima apertura: Aurora cargado del disco. Todas las
244  apps yahweh-themed (4 del repo) comparten la misma preferencia.
245- Failure mode benigno: sin home dir o sin permisos de write,
246  el theme cambia in-memory pero no se persiste — el switcher
247  sigue usable, sólo no sobrevive al close.
248- Path canónico documentado: usuarios que quieran preset el
249  theme antes de abrir la app pueden hacer
250  `echo Aurora > ~/.config/yahweh/theme`.
251
252### feat(minga-explorer): listings de items recientes en cada stat card
253Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3
254números). Ahora cada stat card muestra también un sample de los
255items dentro: hashes truncados de los 5 primeros nodos AST
256(con su `kind`), atestaciones (`content_hash ← did_short`) y
257claves MST. Mucho más útil para debugging que el "tengo N items".
258
259Cambios en `minga-explorer`:
260- **`RepoSnapshot` extendido** con 3 nuevos `Vec<...>`:
261  - `recent_nodes: Vec<(String, String)>` — `(hash_short, kind)`.
262  - `recent_attestations: Vec<(String, String)>` —
263    `(content_hash_short, did_short)`.
264  - `recent_mst_keys: Vec<String>` — `hash_short`.
265  - Cap de 5 items por sección via `RECENT_LIMIT` const.
266- **`load_snapshot` itera los stores** y toma los primeros 5
267  items via `iter().filter_map(Result::ok).take(RECENT_LIMIT)`.
268  Errores per-item se silencian (`filter_map`) — el dashboard
269  muestra lo que pueda; un par de items corruptos no debería
270  tirar el panel.
271- **`short_hash(&str)` helper local**: trunca un hex a sus
272  primeros 12 chars (48 bits, distintivo dentro de un repo
273  single-machine).
274- **`stat_card` extendido**: nuevo arg `recent_items: &[String]`.
275  Si no está vacío, agrega un sub-header `"recent (N de TOTAL):"`
276  + una linea por item. Cada line es texto pequeño (`px(11)`)
277  con el color principal del theme — visualmente queda como
278  monospace listing aunque no use mono font (no hay todavía
279  en el theme).
280
281Tests: 2 → **4** (+2 sanity de los nuevos defaults + del
282`short_hash`).
283
284Beneficio operativo:
285- Después de `minga ingest archivo.rs`, el explorer muestra
286  inmediatamente los hashes de los nodos AST creados, qué `kind`
287  tienen, y las atestaciones firmadas — sin necesitar `minga
288  status` o queries SQL.
289- "5 de 247" da contexto del crecimiento sin overwhelm de
290  listing completo.
291
292Limitación documentada: los "recent" no son cronológicos — sled
293ordena lexicográfico por hash. Para timeline real, agregar
294timestamp al schema (cambio breaking del store, scope futuro).
295
296### feat(minga-explorer): nueva app dashboard del repo Minga sobre stack yahweh
297Iter 11. Cierra el último frente identificado: integración del
298módulo Minga (VCS semántico P2P) al ecosistema GUI. Antes Minga
299sólo tenía CLI (`minga init/status/ingest/listen/sync/watch`).
300Ahora hay un **dashboard GPUI** que muestra los counts del repo
301en vivo, sobre el mismo stack themed que las otras dos apps
302explorer.
303
304Crate nuevo `crates/apps/minga-explorer/`:
305- **Deps**: `minga-store` (para `PersistentRepo::open`) +
306  `yahweh-theme` + `yahweh-widget-{banner,card,theme-switcher}`.
307  Sin `minga-cli` (no necesita prompts de passphrase) ni
308  `minga-core` (counts no requieren parsear AST).
309- **Lectura sin passphrase**: el `PersistentRepo` se abre directo
310  desde `<repo>/repo` sled. Los counts (`nodes.len()`,
311  `attestations.len()`, `mst.len()`) son lectura pública. Para el
312  DID se sigue necesitando `minga status` (CLI con passphrase).
313- **Refresh por polling cada 2s**: mismo pattern que
314  `nakui-explorer`/`nouser-explorer`.
315- **3 stat cards** una por dimensión:
316  - Nodos AST (cyan) — fragments parseados del código.
317  - Atestaciones (verde) — firmas Ed25519 sobre los nodos.
318  - Claves MST (purple) — entradas del Merkle Search Tree.
319- **Helper `stat_card(cx, label, value, description, accent, ...)`**:
320  fabrica una card con border-l colored + label tenue + número
321  grande (`px(28)`) + descripción. Reutilizable.
322- **Header**: título dinámico (`Repo: <path> · reload <ms> ms`)
323  + theme switcher derecha.
324- **Error banner**: themed Banner::Error si el repo no abre.
325- 2 tests: `load_snapshot_errors_on_missing_dir` (msg claro
326  cuando el dir no existe) + sanity del `RepoSnapshot::default`.
327
328Workspace: nueva entry en `members[]`.
329
330Smoke run del binario verificado: bootstrap completo OK, panic
331esperado en open_window por falta de display.
332
333Beneficio operativo:
334- Un usuario corre `minga init` + `minga ingest archivo.rs` desde
335  CLI, después abre `minga-explorer` y ve los counts crecer en
336  vivo cuando ingiere más archivos.
337- Comparte theme switcher con `nakui-explorer` y
338  `nouser-explorer` — cualquier preset elegido se aplica
339  visualmente igual cross-app.
340- `minga` deja de ser sólo CLI; gana presencia GUI sin tocar
341  el resto del módulo.
342
343Apps GUI integradas al stack themed: **4** (nakui-ui, nakui-explorer,
344nouser-explorer, minga-explorer).
345
346### feat(nouser-explorer): integración al stack yahweh themed
347Iter 10. `nouser-explorer` (la app paralela a `nakui-explorer`
348para ver Mónadas via daemon nouser) tenía colors hardcoded
349idénticos al patrón previo. Aplico el mismo refactor que se hizo
350para `nakui-explorer` en iter 4: instala el theme global, migra
351chrome a slots, usa los widgets `banner_themed` / `card_themed` /
352`theme_switcher`.
353
354Cambios en `nouser-explorer`:
355- **Nuevas deps**: `yahweh-theme`, `yahweh-widget-banner`,
356  `yahweh-widget-card`, `yahweh-widget-theme-switcher`.
357- **`main()`**: `Theme::install_default(cx)` antes de
358  `cx.open_window`.
359- **`render`**: 4 vars `let X = rgb(...)` (chrome) → theme slots
360  (`bg_app`/`fg_text`/`fg_muted`/`bg_panel`/`border`).
361- **Header**: gana flex_row + theme switcher en la derecha (mismo
362  pattern que nakui-explorer).
363- **`error_banner`**: pasa de div hardcoded a `banner_themed(cx,
364  Banner::Error, ...)` con override de padding (16/8) por
365  convención del header.
366- **2 cards de Engine y Monad**: pasan de `div().flex().flex_col()
367  .p().mb().bg(card_bg).rounded().border_l_4().border_color()...`
368  a `card_themed(cx).border_l_4().border_color(accent)...`.
369- **Acentos semánticos**: `accent_engine` (cyan, las "máquinas")
370  y `accent_data` (purple, las Mónadas) quedan locales — son
371  señales del dominio nouser, no del chrome.
372
373Tests: workspace stack intacto. nouser-explorer no tiene tests
374propios (siempre fue una vista live del daemon, sin lógica
375testable separada).
376
377Beneficio operativo: las dos apps explorer del repo
378(`nakui-explorer` para event log + `nouser-explorer` para Mónadas)
379ahora comparten la misma paleta themed + el mismo control de
380switcher. Si un usuario las corre lado a lado, la consistencia
381visual emerge sola.
382
383### feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed
384Iters 8-9 combinadas. Tres mejoras pequeñas que cierran la
385integración del theme:
386
387**1. Caret blinking en text-input** (`yahweh-widget-text-input`):
388- Nuevo field `caret_visible: bool` que toggea cada 500ms.
389- Nuevo field `_blink_task: Task<()>` mantiene el loop de blink
390  vivo y lo cancela al drop del widget.
391- En `new()`, `cx.spawn(...)` arranca el loop: `timer.timer(500ms)`
392  + `this.update(...)` que toggea + `cx.notify()`. Si el update
393  falla (entity drop), break.
394- En `render()`, caret `|` se dibuja sólo si
395  `is_focused && self.caret_visible`. Familiar feel del SO.
396
397**2. Slots ornament en yahweh-theme** (5 nuevos):
398- `bg_input() -> Hsla` — bg sutil para fields editables.
399- `bg_button() -> Hsla` + `bg_button_hover() -> Hsla` — controls
400  clickable secundarios.
401- `accent_destructive() -> Hsla` — rojo para acciones peligrosas.
402- `bg_destructive_hover() -> Hsla` — bg de hover sobre destructive.
403- Implementados como **methods** del `Theme` (no fields del
404  struct), derivados via `ornament_slots(self.is_dark)`. Esto
405  evita modificar los 6 presets — el slot vive donde uno lo
406  invoca.
407
408**3. MetaApp ornament cleanup** (`yahweh-widget-meta-form`):
409- 11 colores hardcoded `gpui::rgb(0x...)` migrados a slots del
410  theme:
411  - Sidebar menu items (selected/hover) → `bg_row_active` /
412    `bg_row_hover`.
413  - List row separator + button bgs → `bg_row_active` /
414    `bg_button()` / `bg_button_hover()`.
415  - Icon ✕ delete + hover → `accent_destructive()` /
416    `bg_destructive_hover()`.
417  - EntityRef selector hover/selected → `bg_row_active` /
418    `bg_row_hover`.
419  - EntityRef selector border → `theme.border` (slot existente).
420  - Form fallback input bg + submit button → `bg_input()` /
421    `bg_button()` / `bg_button_hover()`.
422  - Confirm modal hint subtitle + hovers de Cancel/Confirm →
423    `theme.fg_muted` / `bg_button_hover()` / `bg_destructive_hover()`.
424- Pattern: `let X = theme.slot()` antes de las closures + `move |d|
425  d.bg(X)` en hover/when para que el cierre tome ownership.
426
427Antes de este commit MetaApp tenía la **paleta principal** themed
428(iter 5) pero el ornament secundario (hovers, separators, botones
429inline) seguía hardcoded. Ahora el theme switcher cambia
430**absolutamente todo** el chrome del MetaApp en runtime.
431
432Tests: 117 verdes (sin cambios numéricos, pero downstream sigue
433compilando). Smoke run de nakui-ui: bootstrap completo OK.
434
435Limitación restante: `nouser-explorer` todavía no migra al stack
436yahweh themed — patrón idéntico a `nakui-explorer` aplicado pero
437más nuevo. Próxima iter.
438
439### feat(yahweh-widget-text-input): focus-aware border + caret sólo on focus
440Iter 7 (mini-iter — el text-input ya estaba themed, faltaba sólo
441el polish de focus visibility). Antes el border era siempre
442`accent_strong` y el caret `|` siempre estaba presente — imposible
443distinguir cuál input está activo en un form con varios fields.
444
445Cambios en `yahweh-widget-text-input`:
446- **Border focus-aware**: cuando el input está focused, border =
447  `theme.accent_strong` (color vivo). Cuando no, border =
448  `theme.border` (color tenue del chrome). Se obtiene via
449  `self.focus_handle.is_focused(window)`.
450- **Caret `|` sólo on focus**: cuando el input no tiene focus, se
451  muestra el texto plano sin caret. Reduce el "ruido visual" en
452  forms con muchos fields.
453- `render` ahora usa el `Window` arg (antes `_w`) para chequear
454  focus.
455
456Sin cambios en API pública — todo es interno al `render`. El
457binario no requiere migración.
458
459Tests: sin cambios (los tests del crate son struct constructors,
460no rendering). Tests downstream del widget (`yahweh-widget-meta-form`,
461`nakui-ui`) siguen verdes — el cambio es backward compatible.
462
463Beneficio operativo:
464- Forms con 5+ fields ahora son navegables: el usuario ve cuál
465  input recibe sus teclas via el border highlighted.
466- Cambio de theme afecta también a inputs (ya estaban themed; ahora
467  además respetan el `accent_strong` específico del preset
468  cuando focused, vs el `border` cuando no).
469
470Limitación pendiente: el caret `|` literal no parpadea (no hay
471animation timer). Cuando emerja la necesidad, agregar via
472`cx.spawn` con un loop de toggle. Por ahora el caret estático on
473focus es suficiente signal.
474
475### feat(yahweh-widget-theme-switcher): control para ciclar themes en runtime
476Iter 6. Cierra el ciclo del theme: ya teníamos paleta themed +
477widgets que la consumen, faltaba el control UI para rotar entre
478presets en vivo. Ahora hay un botón yahweh que muestra el theme
479actual y al click avanza al siguiente. `nakui-ui` y `nakui-explorer`
480lo incrustan en sus headers — un click cambia toda la paleta.
481
482Crate nuevo: `crates/modules/ui_engine/widgets/theme-switcher/`
483(`yahweh-widget-theme-switcher`):
484- **Deps**: `gpui` + `yahweh-theme`. Sin nada más.
485- **`pub fn theme_switcher(cx: &mut App) -> impl IntoElement`**:
486  botón clickable con `id`, padding consistente (`px(8/4)`),
487  bg = `theme.bg_panel_alt`, hover = `bg_row_hover`. Muestra
488  `"Tema: <name> ▸"` y al click hace
489  `Theme::set(cx, Theme::next_after(current.name))`.
490- 2 tests `#[gpui::test]`:
491  - `switcher_constructs_with_theme_installed` — smoke: el
492    constructor lee el global y devuelve un IntoElement sin panic.
493  - `theme_set_changes_global` — verifica que `Theme::set` reemplaza
494    el global y que el siguiente `Theme::global` devuelve el nuevo.
495- Dev-dep `gpui` con `test-support` para habilitar TestAppContext.
496
497Migración de consumers:
498- **`nakui-explorer`**: nueva dep `yahweh-widget-theme-switcher`.
499  El header pasa de `div().px().py()...child(text)` a
500  `div().flex_row().child(div().flex_grow().child(text)).child(theme_switcher(cx))`.
501  El switcher queda alineado a la derecha vía `flex_grow` del label.
502- **`yahweh-widget-meta-form`**: nueva dep. El sidebar header
503  ("Nakui" + 12px padding) gana el switcher con el mismo patrón
504  flex_row + flex_grow.
505
506Tests stack: 115 → **117** (+2 del switcher). Cada crate compila
507individualmente.
508
509Beneficio operativo:
510- Click en el switcher cambia toda la paleta en vivo: bg del app,
511  panels, banners (los que usan `_themed`), confirm modal, todo.
512- 6 presets disponibles via `Theme::all()` (Nebula, Aurora,
513  Sunset, Flat Dark, Solarized Light, High Contrast). El switcher
514  cicla circularmente.
515- Apps adoptantes del `Theme` heredan el switch sin esfuerzo.
516
517Decisión técnica: el handler usa `Theme::set(cx, ...)` que
518invalida el global. GPUI marca todos los views como dirty y
519re-renderea — los widgets que leen `Theme::global` en su `render`
520ven el nuevo automáticamente. No requiere `cx.observe_global`
521explícito en cada widget consumidor.
522
523Limitación: TextInput entities ya creadas no se actualizan visualmente
524si el theme cambia los colors del input bg/border (esos colors
525están hardcoded en `yahweh-widget-text-input`). Migrar text_input
526al theme es una iter futura — bajo scope porque actualmente vive
527suficientemente bien con sus defaults dark.
528
529### feat(yahweh-widget-meta-form): paleta del chrome migrada a `Theme::global(cx)`
530Iter 5 de integración. El `MetaApp::render` tenía 7 vars locales
531con colors hardcoded (`bg/panel/border/text/text_dim/accent/
532accent_active`) que se pasaban a las funciones internas
533(`render_sidebar`/`render_main`/`render_list`/`render_form`/
534`render_entity_ref_selector`). Ahora salen del `Theme::global(cx)`
535que el binario shell instala al boot. El `confirm_delete_banner`
536también usa `themed_colors(Banner::Warning)` / `themed_colors(Banner::Error)`
537para sus colors base.
538
539Cambios en `MetaApp::render`:
540- 7 `let X = gpui::rgb(0x...)` → derive del theme:
541  - `bg` ← `theme.bg_app` (Background, soporta gradientes).
542  - `panel` ← `theme.bg_panel`.
543  - `border` ← `theme.border` (Hsla).
544  - `text` ← `theme.fg_text`.
545  - `text_dim` ← `theme.fg_muted`.
546  - `accent` ← `theme.accent`.
547  - `accent_active` ← `theme.accent_strong`.
548- `toast_div` y `error_banner`: `banner(...)` → `banner_themed(cx, ...)`.
549
550Cambios de firma (internas, no API público):
551- `render_sidebar` / `render_main` / `render_list` /
552  `render_entity_ref_selector` / `render_form` cambian Rgba →
553  Hsla en sus parámetros de color (Background donde aplica para
554  `panel`). Los métodos `bg/text_color/border_color` de gpui::Div
555  aceptan ambos via `Into`, así que el uso interno no cambia.
556
557Cambios en `render_confirm_delete_banner`:
558- 6 colors hardcoded amber/red/gray → `themed_colors(Warning)` para
559  banner base, `themed_colors(Error)` para botón Confirm,
560  `theme.bg_panel_alt + fg_text` para botón Cancel.
561- Cambiar de Theme ahora cambia toda la paleta del modal.
562
563Lo que **NO** migra esta iter (queda como ornament hardcoded; iter
564futura si emerge la necesidad):
565- Row hovers misceláneos en `render_list` (px 0x232a36 / 0x1f2630
566  para selected/hover de filas).
567- Borders sutiles entre filas (px 0x232a36).
568- Bg de inputs custom (px 0x171a20).
569- Bg de botones en `render_entity_ref_selector` (px 0x2c3540).
570- Color rojo del icon `✕` de delete (px 0xd07070) y su hover
571  (px 0x4a2020).
572
573Estos son detalles ornamentales que un theme switcher real
574querría integrar; los aislo para una pasada futura cuando esté
575claro qué slots semánticos del theme conviene agregar (ej.
576`bg_row_selected` distinto de `bg_row_hover`, `accent_destructive`,
577etc.).
578
579`nakui-ui` shell ya instalaba `Theme::install_default(cx)` desde la
580iter pasada — sigue siendo el contract entre el shell y el widget.
581Smoke test del binario verificado: bootstrap completo OK, panic
582esperado en open_window sin display.
583
584Tests stack: 115 verdes (sin cambio — los tests del widget no
585acceden al render).
586
587Beneficio operativo:
588- El theme switcher (cuando llegue) cambia toda la paleta principal
589  de `MetaApp` con 1 sola llamada `Theme::set(cx, ...)`.
590- `MetaApp` y `nakui-explorer` comparten el mismo theme global en
591  un mismo proceso (si llegan a vivir juntos).
592- Los `confirm_delete_banner` y los toasts del MetaApp respetan
593  is_dark: el contrast ajusta automatic.
594
595### feat(yahweh): theme integration en `banner` + `card` + `nakui-explorer` consume themed
596Iter 4 de la integración. Los widgets `banner` y `card` ahora
597ofrecen variants `_themed(cx, ...)` que leen `Theme::global(cx)`.
598Las versiones sin theme se preservan para apps sin theme global.
599`nakui-explorer` migra a versiones themed + `Theme::install_default`
600al boot — el chrome hardcoded del explorer (5 variables `let bg =
601rgb(...)`) sale del theme.
602
603Cambios en `yahweh-widget-card`:
604- **Nueva dep**: `yahweh-theme`.
605- **`pub fn card_themed(cx: &App) -> Div`**: devuelve [`card`]
606  pre-aplicado con `bg(theme.bg_panel)`. El caller sigue componiendo
607  con borders, accents, children.
608
609Cambios en `yahweh-widget-banner`:
610- **Nueva dep**: `yahweh-theme`.
611- **`pub fn banner_themed(cx: &App, kind, message) -> Div`**:
612  deriva `(bg, fg)` según `kind` + `theme.is_dark`:
613  - `Info`: `theme.bg_panel_alt` + `theme.accent`.
614  - `Success` / `Warning` / `Error`: hue fijo (verde/amber/rojo)
615    + lightness flippeada según `is_dark` (dark = bg low, fg high;
616    light = invertido).
617- **`pub fn themed_colors(kind, theme) -> (Background, Hsla)`**:
618  helper público para callers que quieren computar el par sin
619  construir el div.
620- 3 tests nuevos del derivation: dark/light lightness contrast,
621  kinds distinguidos por hue.
622
623Migración de `nakui-explorer`:
624- Nueva dep `yahweh-theme`.
625- `main()` llama `Theme::install_default(cx)` antes de open_window
626  (el theme default es Nebula).
627- `render`:
628  - 5 `let bg/text/text_dim/card_bg/border = rgb(...)` colors
629    locales → `theme.bg_app/fg_text/fg_muted/bg_panel/border`.
630  - `card().bg(card_bg)` → `card_themed(cx)` (borra los locales).
631  - `banner(Banner::Error, ...)` → `banner_themed(cx, Banner::Error, ...)`.
632  - Los accents `accent_seed` / `accent_morphism` se preservan
633    locales: son **señales semánticas del log** (azul=seed,
634    verde=morphism), no chrome del app.
635
636Distribución de tests: 112 → **115** (+3 del banner derivation).
637Workspace stack pasó por la migración sin errores.
638
639Beneficio operativo:
640- Cambiar de Theme (Nebula → Aurora → Solarized Light, etc.) ahora
641  refleja en `nakui-explorer` automáticamente. Antes había que
642  buscar y reemplazar los hex codes uno a uno.
643- Apps que adopten el patrón `_themed` heredan el switcher de
644  theme cuando emerja.
645
646Decisiones:
647- **Hue fijo por kind**: Success siempre verde, Error siempre rojo,
648  etc. La lightness se ajusta al theme; el hue se mantiene como
649  invariante semántico cross-theme.
650- **API dual**: `banner` (defaults) + `banner_themed` (theme).
651  Apps sin theme global pueden seguir con la versión simple.
652- **Acentos semánticos del explorer (seed/morphism) NO migran**:
653  pertenecen al dominio del log, no al chrome.
654
655Próximas integraciones pendientes:
656- `MetaApp` (en `yahweh-widget-meta-form`) tiene su propia paleta
657  hardcoded de 6 colors que podría migrarse al theme. Scope mayor
658  que esta iter; queda como candidato.
659- Theme switcher widget (botón/menú en chrome para ciclar themes).
660  Cuando emerja la necesidad real.
661
662### feat(yahweh-widget-card): container card-shape compartido para timeline entries
663Iteración 3 de la integración nakui ↔ yahweh. El "card visual"
664pattern (padding consistente + rounded + flex_col + gap) que vivía
665duplicado en cada timeline entry de `nakui-explorer` ahora es un
666widget yahweh reusable. Sin acoplamiento a colores: el caller
667decide bg/border/accent.
668
669Crate nuevo: `crates/modules/ui_engine/widgets/card/`
670(`yahweh-widget-card`):
671- **Dep**: solo `gpui`. App-agnostic.
672- **`pub fn card() -> Div`**: container con `flex_col` + `px(12)`
673  + `py(8)` + `mb(4)` + `rounded(4)` + `gap(2)`. Sin colores
674  aplicados.
675- El return es `Div` GPUI — el caller compone con `.bg(...)`,
676  `.border_l_4()`, `.border_color(...)`, `.child(...)`, hover,
677  on_click, etc., según necesite.
678- 1 test smoke (constructor no panicea).
679
680Migración de `nakui-explorer`:
681- Nueva dep `yahweh-widget-card`.
682- Los 2 patterns de timeline entry (Seed y Morphism) pasan de:
683  ```rust
684  div().flex().flex_col().px(12).py(8).mb(4).bg(card_bg)
685       .rounded(4).border_l_4().border_color(accent).gap(2)...
686  ```
687  a:
688  ```rust
689  card().bg(card_bg).border_l_4().border_color(accent)...
690  ```
691- Reducción ~7 calls → ~3 por entry; legibilidad mejor (la
692  intención "card with accent" emerge del nombre `card()`).
693
694Tests stack: 111 → **112 verdes** (+1 del crate card). Cada crate
695afectado compila y testea individualmente.
696
697Beneficio operativo:
698- Si `MetaApp` o cualquier futura app necesita un container
699  card-shape (ej. info card, expanded list row), `card()` está
700  ya disponible.
701- Cambiar el padding/rounded/gap canónico = un cambio en un solo
702  lugar.
703- El widget no impone colores → no fuerza una paleta y permite
704  themes diversos por app/contexto.
705
706### feat(yahweh-widget-banner): widget compartido para toasts/errores cross-app
707Patrón visual común a `yahweh-widget-meta-form` (toast success +
708error_banner) y `nakui-explorer` (error_banner): un `div` con bg
709+ text colored según severidad. Antes vivía duplicado con colores
710hardcoded en cada consumer; ahora hay un widget yahweh con presets
711consistentes.
712
713Crate nuevo: `crates/modules/ui_engine/widgets/banner/`
714(`yahweh-widget-banner`):
715- **Dep**: solo `gpui` (sin nakui, sin runtime). Reusable por
716  cualquier app GPUI que necesite tiras de status.
717- **`pub enum Banner`** con 4 variants:
718  - `Info` (azul tenue, mensajes neutros).
719  - `Success` (verde, confirmaciones).
720  - `Warning` (amber, llamadas de atención).
721  - `Error` (rojo, errores fatales).
722- **Métodos `Banner::bg()` y `Banner::fg()`**: paleta hardcoded por
723  variant (sin tema dinámico todavía — cuando emerja, se
724  inyecta vía `yahweh-theme`).
725- **`pub fn banner(kind, message) -> Div`**: constructor que
726  devuelve el div ya con padding/text_size defaults; el caller
727  puede agregar children, override pads/sizes, attach handlers.
728- 2 tests sanity: ningún kind comparte bg, ningún kind comparte fg.
729
730Migración de consumers:
731- **`yahweh-widget-meta-form`**: nueva dep `yahweh-widget-banner`.
732  El `toast_div` (Success) y `error_banner` (Error) en
733  `MetaApp::render` pasan de 2x6 líneas hardcoded a una llamada
734  a `banner(...)` cada uno (~12 líneas → 2).
735- **`nakui-explorer`**: nueva dep. El error banner local pasa a
736  `banner(Banner::Error, e).px(16).py(8).text_size(12)` —
737  preserva el padding/size custom del header del explorer via
738  override builder.
739
740Tests stack: 109 → **111 verdes** (+2 del crate banner).
741
742Beneficio operativo:
743- Si emerge un tercer consumer, importa la dep + 1 llamada.
744- Cambiar la paleta de un kind = un cambio en un solo lugar
745  (ej. ajustar tono del Error o el contraste del Warning).
746- Composición preservada: el `banner()` devuelve un `Div` directo,
747  el caller modifica con builder calls (`.child()`, `.px()`,
748  `.on_click()`, etc.) sin rewrap.
749
750Próximo candidato natural: el `confirm_delete_banner` de MetaApp
751es Banner::Warning + 2 botones embedded. Cuando emerja un segundo
752consumer de modal-style banners, extraer un widget compositivo
753arriba del `Banner` base.
754
755### feat(yahweh): `MockBackend` público + tests E2E del widget con `gpui::TestAppContext`
756Cierra el ciclo de testabilidad del widget metainterfaz. Hasta
757ahora los tests del trait `MetaBackend` vivían como impl privada
758en `backend.rs`; el widget no tenía forma de testear handlers
759reales sin levantar `NakuiBackend` (que depende de event log +
760Rhai + nakui-core). Ahora el mock es público y los tests del widget
761lo consumen con `TestAppContext`.
762
763Cambios en `yahweh-meta-runtime`:
764- **Nuevo módulo `pub mod testing`** con
765  `pub struct MockBackend`. Exporta:
766  - `MockBackend::new()` — vacío.
767  - `MockBackend::with_records(iter)` — pre-poblado con
768    `(entity, uuid, value)` tuples.
769  - `MockBackend::with_morphism(name, |inputs, params| -> Result<usize>)` —
770    builder para registrar handlers callable de morphism (sin
771    handler, `morphism()` rebota con error claro).
772  - Métodos de inspección `total_records()` / `records_for(entity)`
773    (último devuelve `Vec<(Uuid, &Value)>` sin clones).
774  - `impl MetaBackend` completo: seed/load/list/update/delete con
775    semantica documentada.
776- **Tests del trait en `backend.rs` simplificados**: el `MemBackend`
777  duplicado se borra; los tests pasan a usar `MockBackend::new()`
778  importado de `crate::testing`. 8 tests del backend.rs intactos +
779  9 tests propios del mock en `testing.rs`.
780- Bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente: los
781  crates downstream pueden importarlo en sus dev/integ tests
782  vía `yahweh_meta_runtime::testing::MockBackend`.
783
784Cambios en `yahweh-widget-meta-form`:
785- **Dev-dep nueva**: `gpui = { workspace = true, features = ["test-support"] }`.
786  Habilita `TestAppContext` para tests sin abrir window real.
787- **`MetaApp::apply_action` ahora `pub`** (era privado). Necesario
788  para que los tests E2E lo invoquen desde fuera. La function ya
789  era el entry point de los click handlers internos; exponerla no
790  cambia el contract.
791- **Nuevo archivo `tests/widget_with_mock_backend.rs`** con 4 tests
792  `#[gpui::test]`:
793  - `meta_app_constructs_with_mock_backend_and_initial_state`:
794    instancia `MetaApp<MockBackend>` con records pre-poblados +
795    toast inicial; valida que la window construye sin panic.
796  - `open_view_action_does_not_panic`: invoca
797    `apply_action(OpenView)` real a través de
798    `window.update(cx, |meta, _, cx| ...)` → state machine corre
799    sin crash.
800  - `backend_state_visible_from_widget_perspective`: demuestra el
801    patrón "backend pre-poblado para fixtures" (typical para
802    screenshots / demos).
803  - `morphism_handler_can_be_registered_and_called_via_widget`:
804    `MockBackend::with_morphism` registra un counter callback;
805    `apply_action(Morphism)` lo dispara via `commit_morphism`
806    sin tocar nakui-core / Rhai.
807
808Helpers de tests:
809- `customers_module()`: fixture local de un `Module` con entity
810  Customer + view list + view form. Reusable cross-test.
811
812Distribución de tests:
813- `yahweh-meta-runtime`: 47 → **56** (+9 del nuevo testing
814  module).
815- `yahweh-widget-meta-form`: 3 → **7** (+4 E2E reales).
816- Total stack: **109 tests verdes** (56 runtime + 31 cards + 12
817  nakui-ui + 3 explorer + 7 widget).
818
819Beneficio operativo:
820- El widget tiene cobertura runtime real, no sólo type-check.
821- Cualquier app que tome `B: MetaBackend` puede testarse con
822  `MockBackend` en sus dev-deps sin re-implementar el mock.
823- Fixtures pre-pobladas habilitan demos/screenshots/CI con state
824  conocido.
825
826Limitaciones:
827- `render()` no se invoca en los tests (requiere window context
828  más rico). Los tests verifican state machine, no pixels. Pixel
829  comparison (snapshot tests) es scope futuro si emerge la
830  necesidad.
831- `apply_action(Morphism)` con un module que no declara
832  `nakui_module_dir` rebota antes de llamar al mock handler. El
833  4to test acepta ambos outcomes (counter 0 o 1) — si en el futuro
834  agregamos un módulo de fixture con nakui_module_dir poblado, el
835  test puede aserta exactamente.
836
837### feat(yahweh-meta-runtime): promover `short_hash` y `preview_value` desde nakui-explorer
838Continúa la integración de las apps nakui al stack yahweh. Los
839helpers visuales que `nakui-explorer` tenía locales y son reusables
840suben a `yahweh-meta-runtime/format` para que cualquier app pueda
841consumirlos sin duplicar.
842
843Cambios en `yahweh-meta-runtime`:
844- **`pub fn short_hash(h: &[u8; 32]) -> String`**: hex de los
845  primeros 4 bytes (8 chars). Útil para mostrar bundle/schema
846  hashes en UI sin quemar pantalla.
847- **`pub fn preview_value(v: &Value, max: usize) -> String`**:
848  JSON one-liner truncado con `...` al final si excede `max`
849  chars. Edge case: `max < 3` devuelve los primeros `max` chars
850  sin sufijo.
851- Re-exports en lib.
852- 5 tests nuevos: 4 tests + 1 sanity para el caso `max < ellipsis`.
853
854Migración de `nakui-explorer`:
855- Nueva dep `yahweh-meta-runtime` en Cargo.toml.
856- Borrado helpers locales `short_uuid`, `short_hash`,
857  `preview_value` (~30 líneas).
858- `use yahweh_meta_runtime::{preview_value, short_hash, short_uuid}`.
859- Borrados 4 tests duplicados (los runtime los testea).
860
861Tests:
862- `yahweh-meta-runtime`: 42 → **47** (+5 helpers nuevos).
863- `nakui-explorer`: 7 → **3** (–4 duplicados; quedan los 3
864  específicos: load_log, breakdown, missing_file).
865- Resto del workspace intacto.
866
867Beneficio operativo: 3 helpers visuales centralizados. Cualquier
868app nueva que muestre UUIDs/hashes/JSON-previews los importa sin
869re-implementar la heurística de truncamiento.
870
871Pendiente arquitectural: el render del card timeline en
872`nakui-explorer` (border-l-4 colored + flex_col + texto en niveles)
873es un pattern reusable que también aparece en `yahweh-widget-meta-form`
874(render_list filas). Cuando aparezca un tercer consumer de ese
875pattern se extrae a un widget yahweh.
876
877### feat(brahman-cards): templates Nickel canónicos para cada body kind
878Materializa el patrón "import + override" del brazo: hasta ahora
879`BRAHMAN_CARDS_TEMPLATES_DIR` existía como mecanismo pero el repo
880no shippeaba ningún template. Ahora hay 3 templates basic (uno
881por body kind del CardBody) bajo
882`crates/core/brahman-cards/templates/`:
883
884- **`ente_basic.ncl`** — Card runtime mínima: `payload="Virtual"`,
885  `supervision="OneShot"`, `schema_version=1`. Override típico:
886  `id` + `label`.
887- **`monad_basic.ncl`** — agrupación semántica de archivos
888  (Mónada Nouser): metadata vacía, `dominant_lens="grid"` (lowercase
889  por convención serde rename_all). Override típico: `id`, `label`,
890  `members`, `cardinality`.
891- **`ui_module_basic.ncl`** — descriptor UI con `entities=[]`,
892  `menu=[]`, `views={}`. Override típico: `id`, `label` y los
893  3 payloads.
894
895Cada field override-able marcada `| default` (sin eso Nickel
896rebota merge de strings/numbers no-iguales).
897
898API nueva en `lib.rs`:
899- **`pub fn canonical_templates_dir() -> PathBuf`**: devuelve el
900  directorio de templates del crate (resuelto via
901  `CARGO_MANIFEST_DIR`). Útil para apuntar el env
902  `BRAHMAN_CARDS_TEMPLATES_DIR` en runtime/tests sin hardcoding
903  del path.
904- Doc explica que para distribución del binary standalone (cuando
905  emerja), incluir templates como recursos via `include_dir!` o
906  instalar el directorio junto al ejecutable.
907
9085 tests E2E (`tests/templates.rs`) que cubren:
909- `ente_basic` import + override `id`+`label` → Card body Ente
910  con `payload=Virtual` (default preserved).
911- `monad_basic` import + override `id`+`label`+`cardinality` →
912  Card body Monad con members=[] y summary="" (defaults).
913- `ui_module_basic` import + override de `id`+`label`+menu+views
914  → Card body UiModule con entities=[] (default).
915- Sanity: import sin override → defaults `"TEMPLATE_ID"` /
916  `"TEMPLATE_LABEL"` pasan al wrapper sin error.
917- Sanity: el path de `canonical_templates_dir()` apunta a un
918  directorio existente con los 3 archivos esperados.
919
920Helper de test `with_canonical_templates(F)` setea/restaura el
921env localmente; cada test single-thread-safe.
922
923Tests suite brahman-cards: 26 → **31** verdes (+5). El resto del
924workspace intacto.
925
926Beneficio operativo:
927- Un usuario que quiera declarar un Card nuevo puede empezar con
928  un override de 2 líneas (`id` + `label`) en lugar de copiar el
929  shape full desde cero.
930- Templates auto-documentan la convención `| default` para que
931  copiar uno y agregar fields propios "just works" en merge.
932- El brazo sigue siendo agnostic — los templates son sólo
933  archivos `.ncl` resueltos via el import resolver Nickel; nada
934  hardcoded en código Rust.
935
936Limitaciones:
937- No hay templates "ricos" tipo `crud_basic.ncl` que parametricen
938  por entity name. Nickel no expone funciones-templates de la
939  forma típica de templating engines; lo más cercano sería un
940  template con un field `entity_name | String` y references
941  internas via `me.entity_name`. Cuando aparezca el caso de uso
942  real (e.g., un módulo donde el patrón list+form es repetitivo),
943  se diseña el template paramétrico.
944- `canonical_templates_dir()` resuelve via `CARGO_MANIFEST_DIR` —
945  funciona en `cargo` (test/run/build) pero no para un binary
946  instalado fuera del workspace. Para release distribution la API
947  necesitará un fallback (resources embedded o convención de
948  install path).
949
950### refactor(nakui-core): KCL → Nickel — `kcl_wrapper` reemplazado por evaluación in-process
951Cierra el ciclo: el motor de validación de entities deja de
952shellear el binario externo `kcl` y pasa a evaluar **Nickel
953contracts** in-process via la dep `nickel-lang` (la misma que ya
954usa `brahman-cards` para sus templates). Los 3 schemas de los
955módulos sales/inventory/treasury migran de `.k` a `.ncl`.
956Además se borran los 2 archivos `.k` doc-only del repo
957(`ente-card/schema/card.k`, `ente-brain/schema/rule.k` — ambos
958estaban marcados "REFERENCE ONLY. NOT LOADED").
959
960Cambios en **nakui-core**:
961- **Nueva dep**: `nickel-lang = "2.0.0"` (interfaz estable).
962- **Borrado** `kcl_wrapper.rs` (43 líneas) — shellear el binario
963  desaparece.
964- **Nuevo** `nickel_validator.rs`:
965  - `pub fn vet(schema_path, state, schema_name) -> Result<(), NickelError>`
966    evalúa `let bundle = (import "<schema>") in
967    (std.deserialize 'Json m%%"<json>"%%) | bundle.<entity>`.
968  - El state JSON va dentro de un raw string Nickel
969    (`m%%"..."%%`) y se deserialize via `std.deserialize 'Json`.
970    No embebemos el state como record literal Nickel directo
971    porque la sintaxis JSON usa `:` (Nickel records usan `=`).
972  - 5 tests propios cubriendo happy path + 4 fallure modes
973    (field missing, predicate fails, cross-field invariant
974    fails, optional field present/absent).
975- **`executor.rs`**:
976  - `kcl_wrapper::vet` → `nickel_validator::vet`.
977  - `KclError` → `NickelError`.
978  - `ExecError::KclPre/KclPost/KclPostCreate` → `SchemaPre/Post/PostCreate`
979    (más neutro, ya no menciona KCL).
980  - `kcl_check` (privado) → `validate_entity`.
981  - `build_schema_bundle` ahora emite un archivo Nickel con
982    `(import "X") & (import "Y") & ...` en lugar de concatenar
983    bytes (cada `.ncl` es una expresión record completa, no
984    juntable como texto plano).
985- **`manifest.rs`**:
986  - `effective_schemas` default `"schema.k"` → `"schema.ncl"`.
987  - `extract_schema_names` reescrito: ahora detecta keys
988    CapitalCase con 2 spaces de indent (convención de los
989    `schema.ncl`), no más patrón `schema X:` de KCL.
990  - Tests del extractor actualizados (1 test reemplazado por 2:
991    `_handles_nickel_record_top_level` + `_skips_let_bindings_and_lowercase`).
992
993Cambios en **schemas de módulos**:
994- **`sales/schema.ncl`**: contracts Nickel para `Venta`. Usa
995  `std.contract.Sequence [record_contract, from_predicate]`
996  para combinar shape + invariante cross-field
997  (`total == cantidad * precio_unitario`). El patrón directo
998  `record | from_predicate` rebota con "missing definition" porque
999  el predicate evalúa el contract antes de que el value lo
1000  populate; documentado en el comment.
1001- **`inventory/schema.ncl`**: `Stock`, `MovimientoStock`,
1002  `TransferenciaStock` (esta última con cross-field
1003  `source != dest` via Sequence).
1004- **`treasury/schema.ncl`**: `Caja`, `Movimiento`,
1005  `Transferencia` (con cross-field via Sequence).
1006- Helpers locales en cada archivo: `positive_int`,
1007  `non_negative_int`, `currency_iso`, etc. via
1008  `std.contract.from_predicate`.
1009- Los 3 `schema.k` viejos **borrados**.
1010- `sales/nsmc.json` actualizado: paths `schema.k` →
1011  `schema.ncl`.
1012
1013Cambios en **tests**:
1014- `sales.rs`, `inventory.rs`: `KclPost` → `SchemaPost`.
1015- `kernel_guards.rs`: `KclPostCreate` → `SchemaPostCreate`,
1016  path del schema directo `treasury/schema.k` →
1017  `treasury/schema.ncl`.
1018- `graph.rs`, `manifest_validation.rs`: tests que escriben
1019  `schema.k` inline cambian a `schema.ncl` con sintaxis Nickel.
1020- `schema_versioning.rs`: refs `schema.k` → `schema.ncl`.
1021
1022Cambios documentales:
1023- **Borrado** `crates/core/ente-card/schema/card.k` (1 archivo,
1024  REFERENCE ONLY documentado en su header).
1025- **Borrado** `crates/core/ente-brain/schema/rule.k` (REFERENCE
1026  ONLY documentado en su header).
1027
1028Tests:
1029- **nakui-core**: 84 tests verdes (41 unit + 43 integration en
1030  graph/event_log/manifest_validation/schema_versioning/
1031  inventory/sales/kernel_guards). Suite full pasa.
1032- **nakui-ui**, **brahman-cards**, **yahweh-***: sin cambios,
1033  todos verdes.
1034- Total cubriendo el área: 174 tests.
1035
1036Beneficios:
1037- **Sin dep externa**: el binario `kcl` ya no es requisito de
1038  runtime ni de tests. Build limpio en CI sin instalar KCL.
1039- **Errores en línea**: Nickel reporta contract violations con
1040  caret pointing al field exacto del schema y el value que
1041  falló. KCL daba mensajes textuales menos navegables.
1042- **Mismo motor que el brazo de cards**: una sola dependencia
1043  Nickel para todo el repo (validación + templates de cards).
1044- **Sin tempfile JSON intermedio**: el state se evalúa
1045  directamente en memoria; no hay `std::fs::write` por cada
1046  validate.
1047
1048Limitaciones / decisiones:
1049- El comentario "REFERENCE ONLY" de los `.k` borrados ya estaba
1050  marcado en sus headers; eran sólo notas de diseño para humanos.
1051  La autoridad real (Rust validate methods) sigue intacta.
1052- La sintaxis Nickel `record_contract | from_predicate` no
1053  funciona — hay que envolver en `std.contract.Sequence [record,
1054  from_predicate]`. Documentado en cada schema y en el doc del
1055  validator.
1056
1057**Pendientes restantes**: ninguno del refactor original. Los
1058yahweh + KCL + card.k cierran. Próximos pendientes salen de
1059nuevo trabajo (no del plan que arrastrábamos).
1060
1061### refactor(yahweh): Fase 2c — extracción del widget al crate `yahweh-widget-meta-form`
1062Cierra el refactor de UI: el widget render (forms, lists, modal de
1063delete, EntityRef selector, sidebar, key handlers) deja de vivir en
1064el binario nakui-ui y pasa a un crate yahweh nuevo, genérico sobre
1065`MetaBackend`. nakui-ui queda como un shell de bootstrap de 424
1066líneas.
1067
1068Crate nuevo: `crates/modules/ui_engine/widgets/meta-form/`
1069(`yahweh-widget-meta-form`):
1070- **Deps**: gpui, yahweh-meta-schema, yahweh-meta-runtime, yahweh-theme,
1071  yahweh-widget-text-input, serde_json, uuid. **Cero deps a nakui** o
1072  brahman-cards — reusable por cualquier app.
1073- **`MetaApp<B: MetaBackend>`** público: estructura genérica con
1074  `modules`, `backend: B`, `active`, `form_inputs`, `editing`,
1075  `pending_delete`, `toast`, `load_error`. El bound `B: MetaBackend`
1076  se propaga a todos los `impl MetaApp<B>` y al `impl Render for
1077  MetaApp<B>`.
1078- **`MetaApp::new(modules, backend, initial_toast, initial_error, cx)`**:
1079  constructor sin lógica de bootstrap. El caller pre-construye
1080  modules + backend + cualquier mensaje inicial. La active view
1081  default es la primera entry del menú del primer módulo.
1082- **Methods preservados** del original (rename simbólico): select_view,
1083  open_edit, commit_seed, commit_morphism, commit_delete, apply_action,
1084  list_rows, render_*, tick interno via WriteOutcome.post_status.
1085- **Helpers locales del widget**: `lookup_field` (path walker JSON
1086  por la lista renderer), `append_compact_msg` (concatenador del
1087  toast), `format_seed_toast` (decide "creado/actualizado/sin cambios"
1088  según `WriteOutcome`).
1089- **3 tests funcionales puros**: `lookup_field`, `append_compact_msg`,
1090  `format_seed_toast`. Tests con GPUI cx no son posibles sin un
1091  TestAppContext setup; quedan implícitos vía type-check del trait
1092  bound.
1093
1094Cambios en `nakui-ui` (shell):
1095- **main.rs**: 1959 → **424** líneas (78% reducción). Ahora sólo:
1096  1. Carga modules via `brahman_cards::load_cards_from_dir` +
1097     `load_ui_modules` (filtra UiModule body, valida, dedup).
1098  2. Carga executors para módulos con `nakui_module_dir`.
1099  3. `NakuiBackend::open(...)` para inicializar el backend.
1100  4. `cx.open_window(...)` con `MetaApp::<NakuiBackend>::new(...)`
1101     como root view.
1102- **`use yahweh_widget_meta_form::MetaApp`** + dep nueva en
1103  Cargo.toml. Los imports de yahweh-meta-runtime/schema desaparecen
1104  de main (los consume el widget internamente).
1105- **Tests del shell**: 4 tests E2E que tocan nakui-core directamente
1106  (event_log_replay, morphism_pipeline_real_sales_vender,
1107  load_ui_modules x3). Los tests del NakuiBackend impl quedan en
1108  `backend.rs` (8 tests). Los tests del widget viven en su propio
1109  crate.
1110- **`backend.rs`**: sin cambios (NakuiBackend ya estaba aislado en
1111  Fase 2b).
1112
1113Distribución final del refactor yahweh:
1114- `yahweh-meta-schema`: 8 tests (data puro).
1115- `yahweh-meta-runtime`: 42 tests (helpers + trait MetaBackend).
1116- `yahweh-widget-meta-form`: 3 tests (widget genérico).
1117- `brahman-cards`: 26 tests (loader unificado).
1118- `nakui-ui`: 12 tests (4 shell + 8 backend impl).
1119- **Total: 91 tests** cubriendo el área.
1120
1121Cada crate compila individualmente. El widget consume el trait sin
1122saber qué backend hay debajo; `nakui-ui` provee el trait wireado a
1123nakui-core; cualquier futuro shell (mock para tests, otro stack de
1124storage) puede reusar el widget sin cambio.
1125
1126Lo que NO hace Fase 2c:
1127- No mueve `format_seed_toast`/`append_compact_msg`/`lookup_field`
1128  a `yahweh-meta-runtime`. Son lo bastante widget-flavored
1129  (`SharedString` de gpui, decisiones de UX del toast, etc.) que
1130  preferí dejarlos al lado del render.
1131- No introduce un `MetaApp::with_status` builder pattern. La
1132  signature de `new` con 5 args es manejable; si crece, se refactor
1133  después.
1134- No expone configuración del widget (theme override, layout
1135  custom, etc.). Cuando emerja una segunda app que use el widget
1136  con preferencias distintas, se agregan opts.
1137
1138**Pendientes**:
11391. **KCL → Nickel**: kcl_wrapper en nakui-core reemplazado por
1140   evaluación de Nickel contracts. Migrar los 3 schemas .k de
1141   sales/inventory/treasury a .ncl.
11422. **`card.k` eliminado** (REFERENCE ONLY documentado en su header).
1143
1144### refactor(yahweh): Fase 2b — `MetaBackend` trait + `NakuiBackend` + MetaUi consume el backend
1145Materialización del trait que diseñamos en charla. Tres pasos
1146combinados en un solo commit:
1147
1148**Step A** — trait + WriteOutcome en `yahweh-meta-runtime`:
1149- Nuevo módulo `backend.rs` con:
1150  - `pub trait MetaBackend: 'static` con 6 métodos:
1151    `list_records`, `load_record`, `seed`, `update`, `delete`,
1152    `morphism`. Convención de ids como `Uuid` canónico (los
1153    backends que internamente usan otros tipos mapean), `set+clear`
1154    pre-computados por el caller (no double-roundtrip al store),
1155    threshold `'static` sin Send/Sync (suficiente para handlers
1156    GPUI single-threaded).
1157  - `pub struct WriteOutcome { id, changed, post_status }` con
1158    constructor `no_change(id)`. La UI usa `changed = 0` para
1159    "sin cambios", `post_status` para concatenar mensajes
1160    auto-emitidos por el backend (compact, etc.).
1161- 9 tests con un `MemBackend` mínimo (HashMap por
1162  `(entity, uuid)`): seed/load round-trip, list/filter/order,
1163  update set/clear/no-op, delete/missing, object-safety check.
1164
1165**Step B** — `NakuiBackend` en `nakui-ui/src/backend.rs`:
1166- Estructura que ownea `Arc<Mutex<MemoryStore>>`,
1167  `Option<Arc<Mutex<EventLog>>>`, `BTreeMap<id, Arc<Executor>>`,
1168  `snap_path`, `snapshot_threshold`, `writes_since_compact`.
1169- `NakuiBackend::open(log_path, threshold, executors) -> (Self, OpenStatus)`:
1170  abre log, carga snapshot, replay, auto-compact si threshold
1171  cruzado; devuelve `OpenStatus { init_toast, load_error }` para
1172  que el caller agregue al banner.
1173- `tick_compact()` privado que cada write public method invoca
1174  tras éxito; devuelve `Option<String>` que se mete en
1175  `WriteOutcome.post_status`.
1176- `impl MetaBackend for NakuiBackend`:
1177  - `seed`: WAL order (log first, store after), `tick_compact`,
1178    devuelve `WriteOutcome { id: Some(uuid), changed: 1, post_status }`.
1179  - `update`: si `set+clear` vacíos devuelve `WriteOutcome::no_change`;
1180    si no construye `FieldOp::Set`+`FieldOp::Clear`, log Morphism
1181    `ui.edit_record` con `params.fields/cleared`, store.apply, tick.
1182  - `delete`: `FieldOp::Delete`, log Morphism `ui.delete_record`,
1183    store.apply, tick.
1184  - `morphism`: locks log + store, `execute_and_log_with_recovery`,
1185    tick. `WriteOutcome { id: None, changed: ops.len(), post_status }`.
1186- Funciones `snapshot_path_for` y `maybe_compact_log` movidas acá
1187  desde main.rs (ahora son detalle del backend).
1188- 7 tests del impl: round-trip via trait, set+clear, no-op edit
1189  no escribe, delete/load, list_records, morphism sin executor da
1190  error claro, threshold dispara snapshot.
1191
1192**Step C** — `MetaUi` consume el backend:
1193- Reemplaza fields `store` / `event_log` / `executors` /
1194  `snap_path` / `snapshot_threshold` / `writes_since_compact`
1195  por un único `backend: NakuiBackend`.
1196- `MetaUi::new` colapsa el wiring de persistencia en
1197  `NakuiBackend::open(...)` — pasó de ~150 líneas a ~10 líneas.
1198- `commit_seed` ya no construye `LogEntry`/`FieldOp` directos:
1199  - SEED → `self.backend.seed(entity, obj)`.
1200  - EDIT → `self.backend.load_record + compute_field_delta +
1201    compute_clear_fields → self.backend.update(set, clear)`.
1202  - Devuelve `WriteOutcome` (reemplaza el viejo enum `CommitOutcome`).
1203- `commit_morphism` parsea inputs/params del form y delega a
1204  `self.backend.morphism(...)`.
1205- `commit_delete` es one-liner: `self.backend.delete(entity, id)`.
1206- `tick_runtime_compact` eliminado (ahora interno al backend; el
1207  msg viaja en `WriteOutcome.post_status`).
1208- `list_rows` queda como proxy `self.backend.list_records(entity)`.
1209- `validate_entity_refs` callsite usa cierre sobre
1210  `backend.load_record` (en vez de `&Store`).
1211- Nuevo helper `format_seed_toast(entity, was_editing, &outcome)`
1212  reemplaza el match sobre `CommitOutcome`.
1213- Imports limpiados: no más `nakui_core::delta::FieldOp`/`FieldPath`,
1214  no más `nakui_core::event_log::*` en main.rs (sólo en tests E2E).
1215  No más `Arc/Mutex` (vive en backend).
1216
1217Distribución de tests post-refactor:
1218- `yahweh-meta-runtime`: 33 → **42** (+9 trait tests con MemBackend).
1219- `nakui-ui`: 14 → **21** (+7 tests del NakuiBackend impl).
1220- `yahweh-meta-schema`: 8 (sin cambio).
1221- `brahman-cards`: 26 (sin cambio).
1222- Total: **97**.
1223
1224Build: cada crate compila individualmente.
1225
1226Nota sobre Fase 2b/c estado:
1227- ✅ Backend trait + impl + MetaUi usa backend.
1228- ⏭ Falta extraer los **widgets render** (form/list/modal/EntityRef
1229  selector) de nakui-ui a un crate yahweh nuevo
1230  (sugerencia: `yahweh-widget-meta-form`). Esa extracción ahora es
1231  trivial: el render code ya consume sólo `&self.modules` +
1232  `self.backend` (vía trait). Lo dejo para próximo commit.
1233
1234**Pendientes**:
12351. **Fase 2c**: extraer widget render al crate yahweh
1236   (`yahweh-widget-meta-form` o similar) — `MetaApp<B: MetaBackend>`
1237   genérico, `nakui-ui` queda como ~50 líneas de shell con
1238   `MetaApp::<NakuiBackend>::new(...)`.
12392. **KCL → Nickel**: kcl_wrapper reemplazado por evaluación de
1240   Nickel contracts.
12413. **`card.k` eliminado** (REFERENCE ONLY).
1242
1243### refactor(yahweh): Fase 2 — extraer helpers puros a `yahweh-meta-runtime`
1244Sigue de la Fase 1 (lift del schema a yahweh). Ahora extraemos los
1245**helpers puros** que cualquier widget renderer o backend ejecutor
1246necesita sobre el schema: parse, delta, validation, format. Sin
1247GPUI, sin acoplamiento a un backend específico.
1248
1249Crate nuevo: `crates/modules/ui_engine/libs/meta-runtime/`
1250(`yahweh-meta-runtime`):
1251- **Deps**: `serde_json`, `thiserror`, `uuid`, `yahweh-meta-schema`.
1252  NO GPUI, NO nakui.
1253- **Módulos**:
1254  - `parse.rs` — `parse_field_value(kind, raw)`,
1255    `infer_param_value(raw)`, `resolve_param_value(name, raw, spec)`.
1256  - `delta.rs` — `compute_field_delta(current, proposed)`,
1257    `compute_clear_fields(current, to_clear)`.
1258  - `refs.rs` — `validate_entity_refs(load: F, refs)` donde `F`
1259    es un cierre `Fn(&str, Uuid) -> Option<Value>`. Decoupling vía
1260    closure en lugar de trait — evita atar el crate a cualquier
1261    backend específico (no hay `Store` trait acá), y los callers
1262    pasan `|e, id| store.load(e, id)` trivialmente.
1263  - `format.rs` — `human_label_for_record(value, id)`,
1264    `render_value(opt_value)`, `value_to_input_text(value)`,
1265    `short_uuid(id)`.
1266- **33 tests propios** en el crate nuevo (cubren todos los helpers
1267  movidos + edge cases).
1268
1269Cambios en `nakui-ui`:
1270- **Nueva dep** `yahweh-meta-runtime` en `Cargo.toml`.
1271- **Imports**: agrega `use yahweh_meta_runtime::{...}` con todos los
1272  helpers extraídos. Borrado el código local equivalente
1273  (~200 líneas).
1274- **`validate_entity_refs` callsite**: pasa de
1275  `validate_entity_refs(&*store, &refs)` a
1276  `validate_entity_refs(|e, id| store.load(e, id), &refs)` — el
1277  closure es ergonómico sobre cualquier `Store`.
1278- **Tests duplicados borrados** (~34 tests que ahora viven en
1279  `yahweh-meta-runtime`):
1280  - `parse_field_*` (text/number/boolean variants)
1281  - `infer_param_value_*`
1282  - `delta_*` (5 tests)
1283  - `clear_fields_*` (3 tests)
1284  - `validate_entity_refs_*` (5 tests)
1285  - `resolve_param_*` (6 tests)
1286  - `parse_field_entity_ref_*` (4 tests)
1287  - `human_label_*` (3 tests), `render_value_*`,
1288    `value_to_input_text_inverse_of_parse`
1289- **Tests que se quedan en nakui-ui** (runtime-específicos):
1290  - `lookup_field_simple_and_nested` — helper local del list renderer.
1291  - `append_compact_msg_handles_both_branches`,
1292    `runtime_compact_cycle_resets_counter_after_threshold`,
1293    `snapshot_path_for_replaces_extension`,
1294    `maybe_compact_log_*` (3) — wiring de persistencia a EventLog.
1295  - `load_ui_modules_via_brahman_cards_*` (3) — integración con el
1296    brazo de cards.
1297  - `value_to_input_then_parse_round_trip` — round-trip del par
1298    `value_to_input_text + parse_field_value` (toca ambos lados).
1299  - `event_log_replay_restores_memory_store`,
1300    `morphism_pipeline_executes_real_sales_vender`,
1301    `event_log_replay_handles_full_crud_cycle` — E2E nakui-core.
1302
1303Distribución de tests:
1304- `nakui-ui`: 48 → 14 (los 34 movidos viven en runtime).
1305- `yahweh-meta-runtime`: 33 (nuevos).
1306- `yahweh-meta-schema`: 8 (sin cambio).
1307- `brahman-cards`: 26 (sin cambio).
1308- Total cubriendo el área: 81.
1309
1310Build: cada crate afectado compila y testea limpio individualmente.
1311Workspace build full no se completó esta corrida por OOM al
1312compilar `surrealdb-core` (problema ambiental no relacionado al
1313refactor).
1314
1315Lo que NO hace Fase 2:
1316- No mueve los widgets render (`render_form`/`render_list`/
1317  `render_entity_ref_selector`/`render_confirm_delete_banner`) a
1318  yahweh — eso es Fase 2b/3, requiere diseñar el `MetaBackend`
1319  trait porque las render functions tocan el state de `MetaUi`
1320  (form_inputs, pending_delete, executors).
1321
1322**Pendientes** (orden):
13231. **Fase 2b**: extraer widget render a un crate yahweh nuevo
1324   (sugerencia: `yahweh-widget-meta-form`). Requiere diseñar
1325   `MetaBackend` trait.
13262. **Fase 3**: thin shell — `nakui-ui` queda reducido a una impl
1327   de backend wireada a `nakui-core`.
13283. **KCL → Nickel** + **card.k eliminado**.
1329
1330### refactor(yahweh): Fase 1 — `nakui-ui-schema` → `yahweh-meta-schema`
1331Primer paso del refactor yahweh. El schema de UI declarativa
1332(entities, menús, listas, formularios, acciones) vivía bajo
1333`crates/modules/nakui/ui-schema/` y se llamaba `nakui-ui-schema` —
1334un nombre que sugería acoplamiento con Nakui que en realidad no
1335existe (el crate sólo depende de `serde`/`serde_json`/`thiserror`).
1336Lo movemos a yahweh para que sea consumible por cualquier app de UI
1337metadata-driven sin hacer pasar la dep "rara" por nakui.
1338
1339Cambios mecánicos:
1340- **`git mv`**: `crates/modules/nakui/ui-schema/` →
1341  `crates/modules/ui_engine/libs/meta-schema/`.
1342- **Cargo.toml del crate movido**:
1343  - `name = "nakui-ui-schema"` → `name = "yahweh-meta-schema"`.
1344  - Description actualizada: "Yahweh — meta-schema: descriptores
1345    declarativos de UI ... independiente del backend".
1346- **Workspace `Cargo.toml`**: la entry del members[] pasa de
1347  `crates/modules/nakui/ui-schema` a
1348  `crates/modules/ui_engine/libs/meta-schema` (en su sección
1349  yahweh, no en la sección nakui).
1350- **`brahman-cards`**:
1351  - Cargo.toml: dep path/name a `yahweh-meta-schema`.
1352  - lib.rs: `pub use nakui_ui_schema::Module` →
1353    `pub use yahweh_meta_schema::Module`.
1354  - readers.rs: comment + doc-link al nuevo nombre.
1355- **`nakui-ui`**:
1356  - Cargo.toml: dep path/name a `yahweh-meta-schema`.
1357  - main.rs: `use nakui_ui_schema::{...}` →
1358    `use yahweh_meta_schema::{...}`.
1359- **Self-test del crate movido**
1360  (`tests/example_modules.rs`): `nakui_ui_schema` → `yahweh_meta_schema`,
1361  y se rebasa el path del repo root (5 niveles arriba ahora, era 4).
1362
1363Cambios documentales:
1364- **Doc de crate** (`lib.rs`): "Schema declarativo de la metainterfaz
1365  Nakui" → "Schema declarativo de la metainterfaz (yahweh
1366  meta-schema)" + "backend-agnostic" en la filosofía. La sección
1367  Persistencia universal pasa de "el runtime conecta cada vista al
1368  `nakui_core::store::Store`" a un wording neutro: "el runtime que
1369  consume este schema conecta vistas a su backend".
1370- **Doc del field `Module.nakui_module_dir`**: ahora marcado como
1371  "path opaco al backend, lo interpreta el runtime concreto". Se
1372  describe la convención actual de Nakui (nsmc.json + KCL + Rhai)
1373  como ejemplo, no como contrato del schema. El nombre del campo
1374  se mantiene por compat con módulos ya escritos; agregado
1375  `#[serde(alias = "backend_module_dir")]` para que un futuro
1376  rename no rompa los actuales.
1377
1378Tests:
1379- yahweh-meta-schema (crate movido): 13 tests propios siguen
1380  verdes tras el path rebase.
1381- brahman-cards: 26/26 verdes (17 integration + 9 nickel).
1382- nakui-ui: 48/48 verdes.
1383- Workspace build verde.
1384
1385Lo que NO hace Fase 1:
1386- No mueve los widgets de UI (form/list/modal/EntityRef selector)
1387  a yahweh — eso es Fase 2.
1388- No introduce un trait `MetaBackend` para desacoplar la lógica
1389  de runtime de Nakui — eso es Fase 3.
1390- No renombra el field `nakui_module_dir`. Se hará cuando aparezca
1391  un segundo backend que también lo necesite.
1392
1393**Pendientes** (orden):
13941. **Fase 2**: extraer widgets render (form/list/modal/EntityRef
1395   selector + helpers parse_field_value/render_value/etc.) a un
1396   nuevo crate `yahweh-widget-meta-form` (o nombre similar).
13972. **Fase 3**: trait `MetaBackend` + thin shell — `nakui-ui` queda
1398   reducido a una impl de backend wireada a `nakui-core`.
13993. **KCL → Nickel**: kcl_wrapper reemplazado por evaluación de
1400   Nickel contracts.
14014. **card.k eliminado** (REFERENCE ONLY).
1402
1403### feat(nakui-ui): migrar consumer al brazo unificado `brahman_cards::load_cards_from_dir`
1404Primera consumer migration del brazo. `nakui-ui` ya no llama a
1405`nakui_ui_schema::load_modules_from_dir` directamente — pasa por
1406`brahman_cards::load_cards_from_dir` y extrae el variant `UiModule`
1407del `CardBody` de cada Card. Beneficios concretos:
1408
1409- **Soporta `.ncl` además de `.json`**: el usuario puede dropear un
1410  `card.ncl` (con templates Nickel + merge) en cualquier subdir y
1411  el runtime lo levanta automáticamente. El layout legacy
1412  `examples/nakui-modules/<id>/module.json` sigue funcionando vía
1413  los filenames default `[card.ncl, card.json, module.ncl, module.json]`.
1414- **Cards de otros body kinds (Ente/Monad) se skipean limpio**:
1415  si el dir contiene Cards no-UiModule, se reportan en un toast
1416  informativo en lugar de fallar la carga.
1417
1418Cambios en `brahman-cards`:
1419- **Nuevo `load_cards_from_dir(dir)`** + variante con readers/filenames
1420  custom. Walkea subdirs (orden lexicográfico), busca el primero de
1421  `DEFAULT_CARD_FILENAMES`, dispatcha al reader. Subdirs sin ningún
1422  filename matching se skipean silenciosamente (permite assets/fixtures
1423  sueltos al lado de los cards). Errores per-file se propagan loud
1424  (sin ocultar corrupción).
1425- **`pub const DEFAULT_CARD_FILENAMES`**: lista canónica probada en
1426  orden. `card.ncl` tiene prioridad sobre `card.json` y sobre los
1427  legacy `module.*`.
1428- **4 tests nuevos del helper**: walk + skip de subdirs sin
1429  card, prioridad ncl > json, propagación loud de errores per-file,
1430  custom filenames.
1431
1432Cambios en `nakui-ui`:
1433- **Nueva dep** `brahman-cards` en `Cargo.toml`.
1434- **Nuevo helper `load_ui_modules(dir) -> (Vec<Module>, Vec<String>)`**
1435  que envuelve `brahman_cards::load_cards_from_dir`, filtra a
1436  UiModule body, valida cada Module con su `validate()`, ordena
1437  por id, y detecta duplicados. El callsite en `MetaUi::new` pasa
1438  a usarlo y al ver Cards skipped emite un toast informativo.
1439- **3 tests nuevos**:
1440  - `load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others`
1441    — verifica que un dir con UiModule + Ente carga el primero y
1442    reporta el segundo en `skipped`.
1443  - `load_ui_modules_via_brahman_cards_rejects_invalid_module` —
1444    `Module::validate()` se sigue aplicando (menu apuntando a view
1445    inexistente rebota).
1446  - `load_ui_modules_detects_duplicate_id` — dos UiModule con
1447    mismo id rebotan con mensaje claro.
1448
1449Tests totales:
1450- `brahman-cards`: 22 → 26 (+4 helper directorio).
1451- `nakui-ui`: 45 → 48 (+3 e2e migración).
1452- Workspace build verde.
1453
1454Lo que NO cambió:
1455- `nakui_ui_schema::load_modules_from_dir` se mantiene intacto (sus
1456  propios tests lo siguen usando, y otros consumers futuros podrían
1457  preferir su error-typing más específico). La migración es opt-in:
1458  `nakui-ui` usa el brazo, ui-schema sigue siendo una API válida.
1459- Layout actual de `examples/nakui-modules/<id>/module.json` no
1460  requiere cambio. Un usuario puede convertir cualquier módulo a
1461  `card.ncl` sin tocar el dir layout.
1462
1463**Pendientes para próximos commits** (orden):
14641. **Yahweh refactor**: lift del MetaUi runtime a
1465   `crates/modules/ui_engine/` para reuso. El brazo + canónico ya
1466   estables, ahora puede extraerse el meta-form widget genérico.
14672. **KCL → Nickel**: kcl_wrapper reemplazado por Nickel contracts;
1468   los 3 schemas .k de nakui modules pasan a .ncl.
14693. **card.k eliminado** (es REFERENCE ONLY documentado).
1470
1471### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
1472Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
1473los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
1474los mismos readers JSON estándar. Un `.ncl` puede producir
1475cualquier `CardBody` siempre que su shape sea reconocida. Los
1476templates funcionan con los `import` + `&` merge nativos de
1477Nickel — el brazo no inventa una mecánica paralela.
1478
1479Cambios:
1480- **Dep `nickel-lang = "2.0.0"`** (interfaz estable, no
1481  `nickel-lang-core` que es internal/inestable). Compila clean
1482  pero suma ~1 min al build cold del crate.
1483- **Nuevo módulo `nickel_eval.rs`** con `eval_nickel_file(path) ->
1484  Result<Value, NickelEvalError>`. Errores tipados:
1485  `Io`, `Eval`, `Export`, `JsonReparse` — el mensaje de Nickel se
1486  formatea como texto plano (sin ANSI) para que sea legible en
1487  logs y toasts.
1488- **`load_card_with` añade `"ncl"`**: lee archivo → eval Nickel →
1489  exporta a JSON → parsea de vuelta a Value → dispatch a los
1490  readers JSON. Pipeline simétrico a `"json"`.
1491- **`CardLoadError::Nickel(NickelEvalError)`**: el error de
1492  Nickel se propaga limpio al error público del brazo.
1493- **Resolución de imports**:
1494  - El parent dir del input se agrega como import path → `import
1495    "./template.ncl"` resuelve sin config.
1496  - El env `BRAHMAN_CARDS_TEMPLATES_DIR` (constante exportada
1497    `BRAHMAN_CARDS_TEMPLATES_ENV`) agrega un registry global →
1498    `import "ui_module_minimal.ncl"` desde cualquier ubicación.
1499  - No hay magic resolución por kind. El autor del Card decide
1500    qué template importa.
1501
1502**Convención obligatoria de templates** (documentada en
1503`nickel_eval.rs`): las fields que el usuario va a sobrescribir
1504deben marcarse `| default` (o `| optional`). Sin ese marker
1505Nickel rechaza el merge de strings/numbers no-iguales con la
1506misma prioridad. Patrón canónico:
1507
1508```nickel
1509# template ui_module_basic.ncl
1510{
1511  id | String | default = "TEMPLATE_ID",
1512  label | String | default = "TEMPLATE_LABEL",
1513  ...
1514}
1515
1516# uso concreto
1517let base = import "ui_module_basic.ncl" in
1518base & { id = "my_id", label = "Mi Label" }
1519```
1520
15219 tests nuevos en `tests/nickel.rs`:
1522- `eval_nickel_file_returns_value_for_valid_input` — happy path.
1523- `eval_nickel_file_surfaces_evaluation_error` — variant `Eval`
1524  con path + message.
1525- `load_card_dispatches_ncl_to_ui_module_variant` — pipeline
1526  e2e a UiModule.
1527- `load_card_dispatches_ncl_to_ente_variant` — pipeline e2e a
1528  Ente.
1529- `template_merge_overrides_id_and_label_only` — el caso del
1530  user: template + override de id+label, resto del template
1531  intacto.
1532- `template_resolves_via_env_registry` — uso del env como
1533  registry global.
1534- `load_card_wraps_nickel_error_in_card_load_error` — wrap
1535  limpio del error.
1536- `nickel_contract_violation_caught_at_eval_time` — value-add
1537  concreto: `id | String = 42` falla en eval, no en deserialize
1538  ni aguas abajo.
1539- `ncl_evaluating_to_unknown_shape_returns_no_matching_reader`
1540  — sanity de coherencia con dispatcher JSON.
1541
154222 tests en total en `brahman-cards` (13 JSON V1 + 9 Nickel V2).
1543Workspace build verde tras la dep nueva.
1544
1545**Lo que NO hace V2** (sigue pendiente):
1546- No migra consumers — `nakui-ui` sigue cargando con
1547  `nakui_ui_schema::load_modules_from_dir`. La migración a
1548  `brahman_cards::load_card` queda para después.
1549- No define un set canonical de templates en el repo (algo
1550  como `templates/ente_basic.ncl`, `templates/ui_module_minimal.ncl`).
1551  Eso emerge cuando aparezcan los primeros casos de uso reales
1552  donde dos cards comparten estructura.
1553- No hace cross-validation entre template + override (ej:
1554  detectar que un override saca un campo required del template).
1555  Nickel ya lo hace via contracts si el template tiene un schema.
1556- No expone una API streaming (load N cards en paralelo). El
1557  use case actual es one-shot al boot.
1558
1559**Pendientes para próximos commits** (orden):
15601. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
15612. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`.
15623. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
1563   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
15644. card.k eliminado (es REFERENCE ONLY documentado).
1565
1566### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
1567**Pivote arquitectónico** decidido en charla: Brahman maneja varios
1568formatos legítimos de "Card" (cada formato vive en su crate origen y
1569conserva su shape público), y un **único brazo** los lee, completa
1570desde templates si vienen simplificados, y los proyecta a UNA sola
1571estructura interna canónica que consumen UI runtime / storage / DHT /
1572wire. Agregar un formato nuevo = agregar un reader, sin tocar
1573consumers.
1574
1575**V1 en este commit**: estructura canónica + readers para los 3
1576formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado
1577para próximo commit).
1578
1579Crate nuevo `crates/core/brahman-cards/`:
1580- **`Card { id, schema_version, lineage, label, extensions, body }`**:
1581  wrapper común con identidad legible + extensiones forward-compat.
1582  `id` como String (no `Ulid`) porque cada body variant usa un tipo
1583  de id distinto (Ulid para Ente/Monad, slug human-friendly para
1584  UiModule). PartialEq omitido del derive porque `MonadManifest` y
1585  `nakui_ui_schema::Module` no lo implementan en sus crates origen.
1586- **`CardBody`** enum etiquetado `kind`:
1587  - `Ente(brahman_card::Card)` — entidad runtime con
1588    payload/soma/supervision.
1589  - `Monad(nouser_card::MonadManifest)` — agrupación semántica de
1590    archivos.
1591  - `UiModule(nakui_ui_schema::Module)` — descriptor de UI con
1592    entities/views/menu.
1593  - Convención: agregar variant nuevo + reader; los consumers que
1594    sólo manejen algunos hacen `match { Ente(..) => ..., _ => skip }`.
1595- **`trait CardReader`**: `name()` + `can_read(&Value) -> bool` +
1596  `read(Value) -> Result<Card>`. El dispatcher prueba en orden y
1597  delega al primero que matchee.
1598- **3 readers concretos** (en `readers.rs`):
1599  - `EnteJsonReader` — heurística: `payload` Y `supervision`
1600    presentes simultáneamente.
1601  - `MonadJsonReader` — heurística: `members` Y `cardinality`.
1602  - `UiModuleJsonReader` — heurística: `entities` Y `views` Y
1603    `menu`. El más específico, va primero en `default_readers()`.
1604- **Entry points**:
1605  - `load_card(path)` — abre archivo, dispatcha por extensión, dentro
1606    de JSON prueba los readers default.
1607  - `load_card_with(path, readers)` — variante con set custom para
1608    apps que quieren restringir formatos.
1609- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`,
1610  `NoMatchingReader`, `ReaderFailed { reader, message }`,
1611  `UnsupportedExtension { ext, supported }`.
1612
161313 tests integration:
1614- 3 detection tests (cada reader matchea sólo su shape, rechaza los
1615  otros 2 + non-object).
1616- 3 dispatch+projection tests (cada formato JSON cargado produce el
1617  variant esperado con campos del wrapper bien derivados).
1618- 2 negative cases (NoMatchingReader, non-object input).
1619- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples
1620  readers — defiende el contrato de orden documentado).
1621- 1 e2e desde disco con `load_card_with`.
1622- 1 unsupported extension.
1623- 1 custom reader set (restringir a sólo Ente).
1624- 1 documented invariant (extensions vacío en V1; si cambia, este
1625  test se rompe como signal).
1626
162713/13 verdes. Workspace build verde tras agregar el crate al
1628`members[]` del workspace Cargo.toml.
1629
1630**Lo que NO hace V1** (explícito):
1631- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda
1632  aislada para no inflar este commit.
1633- No define templates — los templates Nickel se diseñan junto al
1634  reader Nickel (necesitan `merge` nativo de Nickel para fusionar
1635  override + base).
1636- No migra consumers. `nakui-ui` sigue cargando `module.json` con
1637  `nakui_ui_schema::load_modules_from_dir` directo. La migración a
1638  `brahman_cards::load_card` viene cuando V1 + Nickel + templates
1639  estén estables.
1640- No mueve los `extensions` del input a `Card.extensions` — los crates
1641  origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`).
1642  Documentado como decisión consciente.
1643
1644**Pendientes para próximos commits** (orden):
16451. Reader Nickel + template merge.
16462. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
16473. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`
1648   (esperando hasta que el brazo + canónico estén estables).
16494. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
1650   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
16515. card.k eliminado (es REFERENCE ONLY documentado).
1652
1653### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
1654Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
1655sólo validaba **forma** (UUID parseable + trim de whitespace) — un
1656UUID válido pero inexistente en el store pasaba silenciosamente al
1657log/store, dejando dangling references. Ahora validamos también
1658**existencia** contra la entity declarada en `FieldSpec.ref_entity`.
1659
1660Cambios:
1661- **Nuevo helper `validate_entity_refs<S: Store>(store, refs)`**:
1662  - `refs: &[(label, target_entity, uuid)]`.
1663  - Loop fail-fast: primer record ausente → error con label
1664    legible + UUID corto + target entity en el msg
1665    `"campo 'Stock': record abc12345 de 'Stock' no existe en el store"`.
1666  - Pure (toma `&S: Store`), totalmente testable sin GPUI.
1667- **Wireup en `commit_seed`**:
1668  - Durante el parse loop, cuando un field es EntityRef + tiene
1669    `ref_entity` declarado + value parseado a UUID, lo encolamos
1670    en `entity_refs: Vec<(String, String, Uuid)>`.
1671  - Después del parse loop (antes del seed/edit branch), si
1672    `entity_refs` no está vacío, una sola toma del store lock
1673    para validar todos via el helper.
1674  - Falla early: ningún log entry, ningún apply.
1675- **Cobertura**:
1676  - SEED path: alta nueva con EntityRef → validamos antes de
1677    `Seed { data }`.
1678  - EDIT path: edit con EntityRef → validamos antes de calcular
1679    delta. Una optional empty (que iría a clear) no cuenta como
1680    EntityRef (raw vacío skipea el push).
1681  - Morphism inputs: NO se duplica acá. `Executor::compute` ya
1682    valida cada input via `store.load(...).ok_or(EntityMissing)`
1683    antes de correr el script Rhai. Documentado en el doc del
1684    helper.
1685
16865 tests nuevos:
1687- `validate_entity_refs_passes_when_all_records_exist` — happy path.
1688- `validate_entity_refs_fails_on_first_missing` — fail-fast con
1689  msg que incluye entity + UUID corto.
1690- `validate_entity_refs_uses_label_not_entity_in_msg` — el label
1691  legible (ej: "Stock origen") aparece en el error, no la entity
1692  desnuda.
1693- `validate_entity_refs_empty_list_is_ok` — lista vacía es Ok.
1694- `validate_entity_refs_distinguishes_target_from_other_entities` —
1695  un UUID que existe bajo Customer pero NO Stock falla la
1696  validación contra Stock.
1697
169845 tests verdes en nakui-ui (+5). Workspace build verde.
1699
1700Comportamiento esperado:
1701- **Selector clickable es happy path**: el dropdown sólo lista
1702  records existentes, así que clickearlo nunca debería disparar
1703  el error. Sólo dispara con paste manual de UUID que no existe
1704  o records borrados después de la selección (timing race).
1705- **Optional empty no se valida**: si el field es EntityRef
1706  optional y el form lo deja vacío, lo manejamos como "no value"
1707  (skipea el push a `entity_refs`); la lógica de Clear se
1708  encarga del resto.
1709- **Lock contention**: una sola toma del store lock por
1710  `commit_seed`, no una por field. La validación es O(refs) reads.
1711
1712Pendientes restantes:
1713- **Validación KCL del record post-edit** antes de emitir Set/Clear
1714  (hoy `ui.edit_record` no pasa por `Executor::compute`).
1715- **EntityRef cross-module** (referenciar records de OTRO módulo
1716  por nombre, no sólo por entity local).
1717
1718### feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
1719Cierra el último pendiente de UX del round. El edit no podía
1720"borrar" un value vaciando el input — empty optional fields
1721hacían `continue` en `commit_seed`, así que el current value
1722quedaba intacto. Para honrar el intent del usuario ("este field
1723ya no aplica") necesitábamos un FieldOp explícito que remueva
1724la key del map.
1725
1726Cambios en **nakui-core** (la variante es semántica del kernel,
1727no específica de la UI):
1728
1729- **`delta::FieldOp::Clear { path }`** — nueva variante.
1730  Distinta de `Set { value: Null }`: Clear borra la clave; Set
1731  Null deja la clave con valor literal `null`. Importa para
1732  downstream que diferencia "ausente" vs "presente como null"
1733  (ej: serde con `skip_serializing_if = "Option::is_none"`).
1734- **`capability_token`** — Clear devuelve `entity.field`,
1735  mismo shape que Set. Una capability `writes: ["Customer.notes"]`
1736  autoriza tanto Set como Clear sobre ese field.
1737- **`simulate_on`** — Clear remueve la key del Object si el
1738  state es Some(Object). Skip silente si el state es None
1739  (deleted) o no-objeto.
1740- **`MemoryStore::apply_dry_run`** — Set y Clear comparten
1741  pre-condición (record padre existe + es objeto). Pattern
1742  combinado con `|`.
1743- **`MemoryStore::apply`** — Clear hace `map.remove(field)`.
1744  Field ausente = no-op silencioso (post-state idéntico).
1745- **`SurrealStore::apply_dry_run`** — Set/Clear combinados.
1746- **`SurrealStore::apply`** — Clear emite
1747  `UPDATE type::thing UNSET <field>`. El field name viene de
1748  un FieldSpec validado upstream; SurrealQL no soporta binding
1749  de identifiers, así que va inline (con la advertencia
1750  documentada en el comment).
1751- **`Executor` capability check** — Set/Clear comparten match
1752  (mismo token shape, misma resolución a binding role).
1753- **Conservation rules** (en `check_conservation`) NO consideran
1754  Clear — sólo Set. Documentado: morphism authors que querían
1755  clear de un field con conservation tienen que ser cuidadosos;
1756  KCL post-checks pueden capturar violations.
1757
1758Cambios en **nakui-ui**:
1759
1760- **`commit_seed` loop** acumula `to_clear: Vec<String>` con
1761  los nombres de fields optional empty (en lugar de hacer
1762  `continue` silencioso).
1763- **EDIT branch**:
1764  - Computa `set_delta` (igual que antes) + `clear_fields` via
1765    nuevo helper `compute_clear_fields(current, to_clear)`.
1766  - Helper filtra a sólo los fields que actualmente tienen
1767    valor non-null — Clear de un field ausente o ya null no
1768    se emite (sería no-op semántico). Preserva el orden del
1769    input para estabilidad del log entry.
1770  - Construye `ops` combinando Set + Clear.
1771  - NoChange ahora requiere AMBOS vacíos (set_delta y
1772    clear_fields).
1773  - `params` del log entry incluye `cleared: ["field1", ...]`
1774    sólo si non-empty (preserva la shape `fields:` para
1775    edits sin clears).
1776  - `CommitOutcome::Updated.changed = sets + clears` para
1777    que el toast `"actualizado X (N campo(s))"` siga siendo
1778    preciso.
1779
1780Tests nuevos:
1781- **delta.rs**: `simulate_clear_removes_field`,
1782  `simulate_clear_then_set_same_field_keeps_set`,
1783  `clear_capability_token_matches_set_shape`.
1784- **store.rs**: `apply_clear_removes_field_key`,
1785  `apply_clear_on_absent_field_is_noop`,
1786  `dry_run_rejects_clear_on_missing_record`,
1787  `dry_run_rejects_clear_on_non_object`.
1788- **nakui-ui main.rs**: `clear_fields_skips_absent_and_null`,
1789  `clear_fields_preserves_input_order`,
1790  `clear_fields_empty_when_current_is_null`.
1791
179234 tests verdes en nakui-core (+7), 40 en nakui-ui (+3).
1793Workspace build verde. E2E del morphism real
1794`morphism_pipeline_executes_real_sales_vender` intacto — `vender`
1795no usa Clear.
1796
1797Implicaciones:
1798- **El log puede crecer con entries `ui.edit_record` que sólo
1799  tienen `cleared: [...]`** sin `fields`. Esperado y esperable.
1800- **Replay**: las entries con Clear se aplican en orden via
1801  `store.apply(&ops)`. La semantic es deterministic.
1802- **Si un módulo tiene KCL invariants sobre la presencia de un
1803  field**, el usuario podría romper el record vaciando ese
1804  field via UI. Hoy esto NO se chequea — `ui.edit_record` es
1805  un morphism manual que no pasa por `Executor::compute`. Si
1806  esto es un problema, el camino futuro es validar contra el
1807  KCL del entity al submit (otro pendiente).
1808
1809Pendientes restantes:
1810- **Validación cross-field** (ej: UUID del EntityRef existe en
1811  la entity referida).
1812- **Validación KCL del record post-edit** antes de emitir Set/Clear.
1813
1814### feat(nakui-ui): snapshot/compaction durante runtime cada N writes
1815Cierra el último pending del round de persistencia. Antes el compact
1816sólo corría al startup — para una sesión larga con muchas escrituras,
1817el log crecía sin tope hasta el próximo restart, y el siguiente boot
1818pagaba el costo lineal del replay.
1819
1820Cambios:
1821- **Nuevos fields en `MetaUi`**:
1822  - `snap_path: PathBuf` — cacheado del init para que el tick no
1823    tenga que recomputarlo.
1824  - `snapshot_threshold: usize` — leído del env en `new()` y
1825    cacheado. `0` desactiva runtime compact (mismo env y
1826    semantic que el threshold de startup).
1827  - `writes_since_compact: u64` — contador que incrementa por cada
1828    write efectivo y se resetea cuando el threshold dispara
1829    `maybe_compact_log`.
1830- **Nuevo método `tick_runtime_compact()`**:
1831  - Early return si `threshold == 0`.
1832  - Increment + check vs threshold.
1833  - Si cruza: lock log + store, llama `maybe_compact_log`.
1834  - **Si compactó OK**: counter = 0, devuelve msg.
1835  - **Si `maybe_compact_log` returned None** (counter dijo "go"
1836    pero entries < 2): counter = 0 (no re-entrar cada write).
1837  - **Si error**: counter NO se resetea (próximo write reintenta),
1838    devuelve el error.
1839- **Nuevo helper `append_compact_msg(base, opt)`**: concatena el
1840  msg del compact al toast del op original con `";"` separator.
1841- **Wireup en 3 callsites de write efectivo**:
1842  - `apply_action::SeedEntity`: tick si outcome != NoChange.
1843  - `apply_action::Morphism`: tick siempre que Ok.
1844  - Click handler `[Confirmar]` del delete modal: tick si commit_delete Ok.
1845- **NoChange no cuenta**: un edit que no cambia nada no escribe al
1846  log, así que tampoco debería avanzar el counter — preserva la
1847  semantic "1 write = 1 log entry = 1 tick".
1848
18492 tests nuevos:
1850- `append_compact_msg_handles_both_branches` — base solo vs base
1851  + compact, formato del separator.
1852- `runtime_compact_cycle_resets_counter_after_threshold` — E2E
1853  estilo simulación: 7 writes con threshold=3 → 2 compacts (en
1854  write 3 y 6), counter residual = 1, log final con 2 entries
1855  (1 anchor + 1 write residual). Reproduce el algoritmo del tick
1856  sin GPUI cx; si la lógica del método cambia, se rompe como signal.
1857
185837 tests verdes (+2). Workspace build verde.
1859
1860Trade-offs:
1861- **Counter en memoria, no persistido**: si la app crashea entre
1862  compacts, al próximo boot el counter parte de 0. El startup
1863  compact (basado en entry_count del log file) compensa esto:
1864  si quedó mucho post-último-compact, se compacta al boot.
1865- **Lock orden**: tick toma log lock primero, store lock después.
1866  Misma orden que `commit_seed` y `commit_morphism`, no debería
1867  haber deadlock.
1868- **Costo del tick**: 1 increment + 1 compare por write. Cuando
1869  cruza threshold, 1 read del log (entries) + 1 snapshot write +
1870  1 compact. Para threshold=50 es ~1 fsync cada 50 writes —
1871  amortiza bien.
1872
1873Pendientes restantes:
1874- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
1875- **Validación cross-field** (UUID del EntityRef existe en la
1876  entity referida).
1877
1878### feat(nakui-ui): atajo Esc para cancelar el modal de delete
1879Cierra otro pendiente de UX. El banner de confirmación de delete
1880ya tenía botones [Cancelar] / [Confirmar], pero la acción más
1881natural para cancelar un dialog es Esc — y no la teníamos wireada.
1882
1883Cambios:
1884- **`capture_key_down` en el root div** del `MetaUi::render`. Capture
1885  phase (no bubble) para interceptar el Esc *antes* que cualquier
1886  TextInput descendiente lo consuma. Sin pending el handler es
1887  no-op y el evento sigue su flujo normal.
1888- **Match `event.keystroke.key == "escape"`** + `pending_delete.take()`
1889  → toast `"delete cancelado (Entity) [esc]"` (sufijo `[esc]` para
1890  diferenciar visualmente del botón). Si no hay pending, return
1891  temprano sin tocar nada.
1892- **Hint visual en el banner**: subtítulo en amber tenue debajo del
1893  título: `"Esc para cancelar · click [Confirmar] para borrar"`.
1894  Que el usuario descubra el atajo sin RTFM.
1895
189635 tests verdes — el handler de Esc es 8 líneas no-testeables sin
1897GPUI cx (la lógica de pending_delete + toast vive dentro del
1898listener); el wireup compila por type-check.
1899
1900Pendientes restantes:
1901- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
1902- **Snapshot durante runtime** (cada N writes, no sólo al startup).
1903- **Validación cross-field** (UUID del EntityRef existe en la
1904  entity referida).
1905
1906### feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)
1907Cierra otro pendiente: `parse_field_value(FieldKind::EntityRef, raw)`
1908devolvía `Ok(json!(raw))` blindly — el value entraba al log/store
1909incluso si era basura. La validación de UUID sólo ocurría cuando el
1910field se usaba como **input** del morphism (línea ~540 de
1911`commit_morphism`); como **seed field** o como **param**, garbage
1912pasaba silenciosamente.
1913
1914Cambios:
1915- **`parse_field_value(EntityRef, raw)`** ahora hace
1916  `Uuid::parse_str(raw.trim())` y devuelve error claro si falla:
1917  `"'<raw>' no es UUID válido (usá el selector de records)"`. En
1918  caso de éxito, devuelve el UUID **trimmed** como string —
1919  protege contra paste manual con whitespace.
1920- **Doble cobertura**: este path cubre seed fields (commit_seed via
1921  obj.insert) y morphism params (resolve_param_value lo invoca por
1922  cada FieldSpec con kind=EntityRef). El path de morphism inputs
1923  ya validaba antes con `Uuid::parse_str` directo — sigue intacto,
1924  no hay double-validation.
1925- **Selector clickable es happy path**: el dropdown setea valores
1926  bien-formados, así que el usuario nunca debería ver el error en
1927  uso normal. Sólo dispara con paste manual o si el usuario escribe
1928  garbage en el input — defensivo.
1929
19305 tests nuevos (reemplazan al obsoleto `parse_field_entity_ref_returns_string`):
1931- `parse_field_entity_ref_accepts_valid_uuid` — happy path.
1932- `parse_field_entity_ref_trims_whitespace` — `"  uuid\n"` → `"uuid"`.
1933- `parse_field_entity_ref_rejects_non_uuid` — `"abc-123"` → error
1934  con el value y la palabra "UUID" en el mensaje.
1935- `parse_field_entity_ref_rejects_empty_string` — `""` → rebota.
1936- `resolve_param_strict_entity_ref_propagates_error` — sanity de
1937  que el wireup en resolve_param_value hereda el strict checking,
1938  con label del FieldSpec en el mensaje.
1939
194035 tests verdes (+4 net). El E2E del morphism real
1941`morphism_pipeline_executes_real_sales_vender` sigue verde — sus
1942inputs van por el path dedicado, no por parse_field_value.
1943
1944Pendientes restantes:
1945- **Atajo Esc para Cancelar** del modal de delete.
1946- **`FieldOp::Clear`** — para soportar borrar un value vía form.
1947- **Snapshot durante runtime** (cada N writes, no sólo al startup).
1948- **Validación cross-field** (ej: el UUID del EntityRef existe en
1949  la entity referida) — hoy sólo validamos forma; un UUID válido
1950  pero inexistente sí pasa.
1951
1952### feat(nakui-ui): snapshot/compaction automático del event log al startup
1953Cierra el último gran pendiente del round: el replay full cada
1954startup escala lineal en el log. Con 60+ entries el costo de boot
1955se nota; con 10k entries es prohibitivo. Wireamos el snapshot
1956machinery que ya estaba en `nakui-core` (`Snapshot`,
1957`replay_with_snapshot_into`, `EventLog::compact_through`) al
1958runtime de la UI.
1959
1960Cambios:
1961- **Path del snapshot**: sibling del log, extensión `.snap.json`.
1962  `nakui-ui-state.jsonl` ↔ `nakui-ui-state.snap.json`.
1963- **Nuevo helper `snapshot_path_for(log_path)`** — derivación
1964  pura, testeable.
1965- **Nuevo helper `maybe_compact_log(log, snap_path, store, threshold)`**:
1966  - Si `entry_count >= threshold` y `>= 2`, captura
1967    `Snapshot::from_memory_store(store, next_seq - 1)`, lo escribe
1968    atómicamente, y compacta el log dejando la última entry como
1969    anchor.
1970  - Anchor invariant: `EventLog::open` deriva `next_seq` del primer
1971    entry del archivo. Si compactáramos *todo* el log file, al
1972    reabrir el cursor volvería a 0 y el próximo append crashearía
1973    con `NonMonotonic`. Por eso compactamos sólo hasta
1974    `next_seq - 2` — la entry del `snap.seq` queda como anchor del
1975    cursor; `replay_with_snapshot_into` la skipea porque snap ya
1976    cubre hasta ese seq inclusive.
1977  - Threshold via env `NAKUI_SNAPSHOT_THRESHOLD`, default 50.
1978    `0` desactiva por completo.
1979  - Devuelve `Result<Option<msg>, String>`: `Ok(Some)` si compactó,
1980    `Ok(None)` si no había payoff, `Err` si snap o compact fallaron.
1981- **`MetaUi::new` reescrito**:
1982  - Carga snapshot al inicio (Some/None según exista).
1983  - `replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store)`
1984    en lugar de `replay_into`.
1985  - Después del replay corre `maybe_compact_log` con el threshold.
1986  - Toast inicial menciona snapshot loaded si aplica
1987    ("snapshot @ seq K") y la compactación si ocurrió.
1988  - Errores de snapshot load **no son fatales**: cae a full replay
1989    con un msg en el banner.
1990  - Errores de auto-compact **no son fatales**: el log + snap
1991    quedan como estaban, msg al banner.
1992
19935 tests nuevos:
1994- `snapshot_path_for_replaces_extension` — `.jsonl` → `.snap.json`,
1995  edge case sin extensión.
1996- `maybe_compact_log_below_threshold_noops` — 5 entries vs threshold
1997  50: no toca nada, no escribe snap.
1998- `maybe_compact_log_threshold_zero_noops` — threshold 0 = disabled.
1999- `maybe_compact_log_then_reopen_preserves_records` — E2E:
2000  - Escribe 60 seeds (log + store en sync).
2001  - Compacta (60 >= 50): snap escrito, log queda con 1 anchor entry,
2002    msg reporta "59 entries dropped (1 anchor kept)".
2003  - Reopen: `next_seq=60` se preserva via anchor, `entries.len()=1`.
2004  - Replay con snap loadado en store fresco: los 60 records están.
2005  - Segunda corrida del compact con threshold=1: no-op (idempotente).
2006
200731 tests verdes (+5). Workspace build verde tras la nueva firma.
2008
2009Trade-offs y notas:
2010- **Fail-soft**: cualquier error de snap/compact no rompe el boot;
2011  la UI sigue funcionando con full replay y el toast lo reporta.
2012  Sólo `EventLog::open` failing es no-recoverable (pierde
2013  persistencia).
2014- **Crash-safety**: WAL order preservado — escribimos snap (atómico
2015  via tempfile + fsync + rename) ANTES de compactar el log
2016  (atómico igual). Si crasheamos entre los dos, próximo boot ve
2017  snap@K + log con todas las entries 0..N — replay skippea las que
2018  snap cubre, outcome idéntico.
2019- **Sólo en startup**: no hay snapshot durante runtime. Para sesiones
2020  largas con muchas escrituras, el log puede crecer arbitrariamente
2021  hasta el próximo restart. Pendiente futuro: snapshot N writes
2022  desde el último compact.
2023- **Anchor entry sobrevive sin uso útil**: el costo es 1 línea JSON
2024  por compact. No es preocupación a menos que el threshold sea
2025  muy chico (cada compact deja 1 línea de basura).
2026
2027Pendientes restantes:
2028- **EntityRef validation post-submit** — validar UUID parseable al
2029  submit en lugar de al execute del morphism.
2030- **Atajo Esc para Cancelar** del modal de delete.
2031- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
2032- **Snapshot durante runtime** (cada N writes, no sólo al startup).
2033
2034### feat(nakui-ui): edit delta-only — sólo campos modificados al log/store
2035Antes de este cambio, editar un record emitía un `FieldOp::Set` por
2036**cada field del form**, incluso los no tocados. Eso bloata el log
2037(replay tenía que aplicar N ops cuando 1 alcanzaba) y oscurece el
2038intent en una auditoría posterior. Con delta-only, el edit emite
2039sólo los Sets cuyo value nuevo difiere del actual; un edit que no
2040cambia nada deja el log intacto.
2041
2042Cambios:
2043- **Nuevo helper `compute_field_delta(current, proposed)`** — toma
2044  el record actual del store (un `Value`, posible `Null` si el
2045  record no existe) y el `Map` propuesto desde el form, y devuelve
2046  sólo las entries que difieren. Comparación: `PartialEq` estructural
2047  de `serde_json::Value` (un `Null` en current = todos los proposed
2048  son nuevos).
2049- **Nuevo enum `CommitOutcome`**:
2050  - `Created(Uuid)` — alta nueva.
2051  - `Updated { id, changed }` — edit con N campos modificados.
2052  - `NoChange(Uuid)` — edit sin diferencias (el toast lo refleja
2053    como "X sin cambios — no log entry").
2054- **`commit_seed` en path EDIT**:
2055  - Carga current via `store.load(entity, id)` con fallback a
2056    `Value::Null`.
2057  - Calcula delta. Si vacío → return early sin tocar log ni store.
2058  - Si no vacío → emite `Morphism { ui.edit_record, ops: [Set...] }`
2059    con `params.fields` reflejando el delta (no todo el form),
2060    haciendo la auditoría grep-able por field cambiado.
2061- **Toast del callsite**:
2062  - `creado X uuid` (Created)
2063  - `actualizado X uuid (N campo(s))` (Updated)
2064  - `X uuid sin cambios — no log entry` (NoChange)
2065- **`editing` se limpia incluso en NoChange** — el modo edit cierra,
2066  el form vuelve al state limpio.
2067
20685 tests nuevos del helper:
2069- delta vacío cuando todo coincide.
2070- delta sólo con el field cambiado.
2071- delta full cuando current = Null (record no existe).
2072- distingue int 100 de string "100".
2073- ignora fields del current que no están en proposed.
2074
207527 tests verdes (+5). El path SEED no cambió; el E2E del morphism
2076real sigue verde.
2077
2078Limitación conocida (consistente con pre-delta): el form no puede
2079**borrar** un value vaciando el input — empty optional fields hacen
2080`continue` antes de llegar al delta. Para clearear un value hay que
2081declarar el field como required, o esperar a un `FieldOp::Clear`
2082futuro (no necesario hoy: ningún demo lo requiere).
2083
2084Pendientes restantes:
2085- **Snapshot/compaction** del log (replay full cada startup escala
2086  mal con repos grandes).
2087- **EntityRef validation post-submit** — validar UUID parseable al
2088  submit en lugar de al execute del morphism.
2089- **Atajo Esc para Cancelar** del modal de delete.
2090- **`FieldOp::Clear`** — para soportar borrar un value vía form.
2091
2092### feat(nakui-ui): confirmación de delete vía banner modal antes de borrar
2093Cierra el primer pending del último round: borrar un record pedía un
2094solo click en `✕` y se ejecutaba inmediatamente (irreversible —
2095queda en el log como `Morphism { name: "ui.delete_record" }`). Ahora
2096hay un paso intermedio: el click marca el record como pendiente y el
2097banner amber al tope de la ventana ofrece [Cancelar] o [Confirmar].
2098
2099Cambios:
2100- **Nuevo state `MetaUi.pending_delete: Option<(String, Uuid)>`**.
2101  Set en el click del `✕`; limpiado por:
2102  - [Cancelar] → toast "delete cancelado (Entity)".
2103  - [Confirmar] → llama `commit_delete` (igual que antes) y emite
2104    el toast usual.
2105  - Navegación a otra view (`select_view`) — el record marcado
2106    podría no estar visible en la nueva pantalla.
2107- **Click handler de `✕` ya no llama `commit_delete`**: sólo setea
2108  `pending_delete` y limpia toast. La acción destructiva ahora vive
2109  exclusivamente en el botón [Confirmar] del banner.
2110- **Nuevo método `render_confirm_delete_banner`**: devuelve
2111  `Option<Div>` (None si no hay pending). Banner amber con el texto
2112  `¿Borrar {Entity} {short_uuid}?` + dos botones. Renderea como
2113  sibling del row sidebar+main en `flex_col` raíz — no es overlay
2114  flotante (GPUI no expone z-index trivialmente), pero la posición
2115  fija al tope + color amber lo hacen imposible de ignorar.
2116- **Limpieza pre-commit**: `pending_delete = None` se ejecuta antes
2117  de `commit_delete`, así un fallo del commit no deja el banner
2118  colgado además del toast de error.
2119
212022 tests verdes — la lógica del store/log no cambió, sólo el state
2121machine de UI. La confirmación es puramente UX/state, no testable
2122sin GPUI cx, pero la compilación garantiza wireup correcto de las
2123closures.
2124
2125Pendientes restantes:
2126- **Snapshot/compaction** del log para repos grandes (replay full
2127  cada startup escala mal).
2128- **Edit delta-only** — sólo campos modificados, no todos.
2129- **EntityRef validation post-submit** — validar UUID parseable
2130  al submit en lugar de al execute del morphism.
2131- **Atajo de teclado Esc para Cancelar** — requiere event
2132  dispatcher de GPUI, fuera de scope inmediato.
2133
2134### feat(nakui-ui): validación estricta de params del morphism vía FieldKind del FieldSpec
2135Cierra el último trade-off documentado: `infer_param_value` adivinaba
2136el tipo de cada param por la shape del string (i64 → f64 → bool →
2137string). Ahora cuando hay `FieldSpec` declarado, usamos
2138`parse_field_value(spec.kind, raw)` — un Boolean field con value
2139"abc" rebota con mensaje claro en la UI antes de llegar al morphism
2140Rhai (donde el error sería opaco como "Function not found: * ((), ())").
2141
2142Cambios:
2143- **Nuevo helper `resolve_param_value(field_name, raw, spec)`**:
2144  - Si hay `FieldSpec`: validación de `required` (rebota empty con
2145    "param 'X' es obligatorio y está vacío") + parseo estricto via
2146    `parse_field_value(spec.kind, raw)`. Errores incluyen el `label`
2147    del spec para que el toast sea interpretable.
2148  - Si NO hay spec (param declarado en `Action::Morphism.params`
2149    que no existe en `form.fields` — módulo mal-formado): fallback
2150    a `infer_param_value` como red de seguridad.
2151  - Empty + opcional → `Value::Null`.
2152- **`commit_morphism` simplificado**: el loop de params ahora es
2153  3 líneas (lookup spec + llamada a `resolve_param_value` +
2154  inserción al map). La lógica vive en el helper standalone,
2155  testable sin GPUI.
2156
2157Tests: 6 nuevos en `tests` mod, todos contra `resolve_param_value`:
2158- `resolve_param_strict_number_parses_i64` — happy path.
2159- `resolve_param_strict_boolean_rejects_non_boolean` — un Boolean
2160  con "abc" rebota con mensaje que incluye el label.
2161- `resolve_param_strict_number_rejects_garbage` — Number con "abc"
2162  rebota.
2163- `resolve_param_required_empty_rejected` — required vacío rebota
2164  con "obligatorio".
2165- `resolve_param_optional_empty_returns_null` — optional vacío
2166  → null.
2167- `resolve_param_no_spec_falls_back_to_infer` — el fallback
2168  preserva el comportamiento anterior para back-compat.
2169
217022 tests verdes en nakui-ui (+6). E2E del morphism real
2171(`morphism_pipeline_executes_real_sales_vender`) sigue verde — la
2172validación estricta no rompe el path correcto, sólo agrega rebotes
2173tempranos a values mal-tipados.
2174
2175Beneficio operativo:
2176- Mensaje de error en la UI ahora identifica el field problemático
2177  por su label legible ("param 'Cantidad': 'abc' no es número")
2178  en lugar del error opaco del morphism Rhai.
2179- Errores se ven antes de tocar el log o el store — ningún cambio
2180  parcial.
2181- El módulo Nakui ya no tiene que defender contra inputs garbage
2182  desde la UI: la metainterfaz se vuelve la primera línea de
2183  validación tipada.
2184
2185Pendientes futuros (orden de prioridad):
2186- **Confirmación de delete** — modal antes de borrar.
2187- **Snapshot/compaction** del log para repos grandes.
2188- **Edit delta-only** — sólo campos modificados, no todos.
2189- **EntityRef validation post-submit**: hoy `parse_field_value`
2190  para EntityRef devuelve string raw; el commit_morphism luego
2191  valida como Uuid sólo cuando es input del morphism. Para
2192  EntityRef como param, podríamos validar UUID al submit.
2193
2194### feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
2195Cierra el principal trade-off documentado del commit anterior:
2196"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
2197un campo `entity_ref` que apunta a una entity y el runtime renderea
2198una lista clickable de records existentes; click selecciona, el
2199UUID queda guardado para el submit.
2200
2201Schema `nakui-ui-schema`:
2202- **Nueva variante `FieldKind::EntityRef`** (serializa como
2203  `"entity_ref"` en JSON).
2204- **`FieldSpec.ref_entity: Option<String>`** nuevo. Indica qué
2205  entity ofrecer en el selector. `validate()` chequea que cualquier
2206  field con `kind=entity_ref` tenga `ref_entity` set.
2207- Nuevo error tipado `SchemaError::EntityRefMissingTarget`.
2208
2209Runtime `nakui-ui`:
2210- **`render_entity_ref_selector(field_name, target_entity, ...)`** —
2211  helper que arma la lista debajo del input. Cada item:
2212  - Etiqueta humana via `human_label_for_record` (heurística:
2213    `name` → `label` → `title` → `sku` → `sku_id` → fallback al
2214    UUID corto).
2215  - Click handler vía `cx.listener` que llama
2216    `input.set_text(uuid_completo)` — el TextInput interno queda
2217    como source-of-truth, así que `commit_seed` y `commit_morphism`
2218    leen el UUID seleccionado sin saber que vino de un selector.
2219  - Highlight en accent color cuando el item es el actualmente
2220    seleccionado (compara contra el contenido del TextInput).
2221- **`parse_field_value(EntityRef, raw)`** devuelve string del raw
2222  (la validación como Uuid ocurre downstream en `commit_morphism`).
2223- Mensaje "(sin {entity}: creá uno antes para referenciar)" cuando
2224  la lista está vacía — el user sabe qué hacer en lugar de quedarse
2225  trabado.
2226
2227Demo actualizado: `examples/nakui-modules/sales_engine/module.json`:
2228- `vender_form.fields.stock_id_input` y `caja_id_input` cambian de
2229  `kind: "text"` a `kind: "entity_ref"` con `ref_entity: "Stock"`
2230  y `"Caja"` respectivamente.
2231- Ahora el flujo "Vender" es: (1) click en una Stock listada bajo
2232  el input, (2) click en una Caja, (3) escribir venta_id/cantidad/
2233  precio_unitario/timestamp, (4) submit. Sin copiar UUIDs.
2234
2235Tests:
2236- 2 nuevos en schema: `validate_catches_entity_ref_without_target`
2237  y `entity_ref_with_target_validates_clean`. 8 totales.
2238- 4 nuevos en runtime: `parse_field_entity_ref_returns_string`,
2239  `human_label_for_record_prefers_name_over_id`,
2240  `human_label_falls_back_through_label_title_sku`,
2241  `human_label_falls_back_to_id_when_no_known_keys`. 16 totales.
2242- Integration de los 7 demos sigue verde — el demo `sales_engine`
2243  ahora valida con EntityRef + ref_entity correctamente set.
2244
224529 tests totales nakui-ui + schema, 100% verde. El demo
2246`sales_engine` carga limpio con la nueva forma del schema.
2247
2248Pendientes futuros:
2249- **Confirmación de delete** — modal antes de borrar.
2250- **Snapshot/compaction** del log para repos grandes.
2251- **Edit delta-only** (sólo campos modificados).
2252- **Validación de tipos en params del morphism**: `FieldKind`
2253  declarado en el FieldSpec se podría usar para forzar parseo
2254  estricto en `commit_morphism` en lugar de la heurística
2255  `infer_param_value`.
2256
2257### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
2258Cierra el último gran TODO de la metainterfaz Nakui: las acciones
2259`Action::Morphism` ya no son un toast informativo; despachan al
2260`Executor` cargado del manifest nakui-core (`nsmc.json` + schemas
2261KCL + scripts Rhai), pasando por el pipeline completo de Nakui:
2262compute (con dry-run + KCL post-checks) → log append → store apply.
2263
2264Schema `nakui-ui-schema` extendido:
2265- **`Module.nakui_module_dir: Option<String>`** nuevo. Path
2266  (relativo al directorio del `module.json` o absoluto) a un módulo
2267  nakui-core. Sin esto, las Action::Morphism del módulo quedan
2268  no-op con toast informativo. Las Action::SeedEntity siguen
2269  funcionando sin manifest (alta administrativa).
2270- **`Action::Morphism`** ganó dos campos opcionales:
2271  - `inputs: BTreeMap<String, String>` — mapeo `role → field_name`.
2272    Por cada input declarado en el `MorphismSpec.inputs`, indica
2273    qué field del form contiene el UUID del record. El runtime
2274    parsea como `Uuid` y lo pasa al `execute_and_log`.
2275  - `params: Vec<String>` — lista de fields cuyos values van al
2276    `params` JSON. Si vacío, todos los fields no-input van a params.
2277
2278Runtime `nakui-ui`:
2279- **`MetaUi.executors: BTreeMap<String, Arc<Executor>>`** nuevo.
2280  Carga `Executor::load_module(nakui_module_dir)` en `MetaUi::new`
2281  por cada módulo UI que declare la entry. Errores de carga van al
2282  banner; el módulo sigue cargado para SeedEntity, sólo Morphism
2283  queda no-op.
2284- **`commit_morphism(mod_idx, name, inputs_map, params_fields)`** nuevo.
2285  Resuelve inputs (parsea cada field como Uuid), arma params (Value
2286  object con tipos inferidos via `infer_param_value` — int/float/
2287  bool/string), llama `execute_and_log_with_recovery`. Toast con
2288  cantidad de ops aplicadas o el error tipado.
2289- **`infer_param_value`** nuevo helper: heurística simple para
2290  pasar values del form al morphism con tipo inferido (i64 → f64 →
2291  bool → string).
2292
2293Tests: 2 nuevos:
2294- `infer_param_value_int_then_float_then_bool_then_string` —
2295  cobertura de la heurística.
2296- **E2E `morphism_pipeline_executes_real_sales_vender`** —
2297  carga el módulo real `crates/modules/nakui/modules/sales`,
2298  arma store + log, ejecuta el morphism `vender` con inputs
2299  Stock+Caja y params (cantidad=5, precio_unitario=200,
2300  venta_id, timestamp). Asserta:
2301  - el morphism produce ops (no vacío).
2302  - stock.cantidad bajó 100 → 95.
2303  - caja.saldo subió 1_000_000 → 1_001_000.
2304
230512 tests verdes en nakui-ui (+1 vs commit anterior). Schema
2306extension no rompió nada (6 unit + 5 integration siguen verdes).
2307
2308Demo nuevo: **`examples/nakui-modules/sales_engine/module.json`**
2309- Apunta a `crates/modules/nakui/modules/sales` vía `nakui_module_dir`.
2310- 6 vistas: list + form para cada Stock, Caja, Venta + form
2311  "Vender" con `Action::Morphism { name: "vender", inputs: {stock,
2312  caja}, params: [venta_id, cantidad, precio_unitario, timestamp] }`.
2313- El user crea Stocks + Cajas con seed_entity, copia los UUIDs
2314  cortos a los inputs de "Vender", y ejecuta el morphism real:
2315  stock baja, caja sube, Venta se persiste, todo loggeado.
2316- Validaciones KCL fallan limpio (toast con error) si el morphism
2317  rebota — p. ej. cantidad > stock disponible.
2318
2319Activación full:
2320```sh
2321NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
2322NAKUI_MODULES_DIR=examples/nakui-modules \
2323cargo run -p nakui-ui
2324# Sidebar gana "Ventas (con morphism)" — los 6 menús aparecen y
2325# el form "Vender" dispara el pipeline nakui-core completo.
2326```
2327
2328Trade-offs documentados:
2329- **Inputs UUID a mano**: el form pide que el user copie el UUID de
2330  un Stock/Caja existente. Para UX seria habría que agregar
2331  `FieldKind::EntityRef { entity }` que renderiza un dropdown — no
2332  hecho por scope, queda como nice-to-have.
2333- **Inferencia de tipo en params**: `infer_param_value` adivina
2334  por shape del string. Para casos sutiles (ej. "true" como string
2335  literal vs bool), el módulo nakui-core puede explicitar tipos
2336  via `kind` en el FieldSpec — el form lo respeta para validación
2337  pre-submit; la inferencia final sigue siendo heurística.
2338
2339### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
2340Cierra "no hay UI para editar/borrar records existentes" del commit
2341anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
2342el form view se reusa para alta y para edit; el delete es inline.
2343Las mutaciones pasan por `LogEntry::Morphism` con sus ops, así el
2344replay restaura el estado correcto.
2345
2346Cambios:
2347
2348- **`MetaUi.editing: Option<(String, Uuid)>`** nuevo. Set al click
2349  en ✎; cleared al cambiar de view o tras submit exitoso.
2350- **`open_edit(mod_idx, entity, id, cx)`**: setea `editing`, busca la
2351  primera Form view del módulo cuya `entity` matchee, navega ahí. Si
2352  el módulo no tiene Form para esa entity → toast con error
2353  ("no hay form view para entity X").
2354- **`select_view`** extendido: cuando carga un Form, si `editing`
2355  matchea esa entity y el record existe en el store, pre-llena cada
2356  input con el valor del record (vía nuevo helper
2357  `value_to_input_text` — inverso de `parse_field_value`).
2358- **`commit_seed`** ramifica:
2359  - **Edit path** (cuando `editing.is_some()` y entity matchea):
2360    emite `LogEntry::Morphism { name: "ui.edit_record", ops:
2361    [Set { path, value } for each field], params: { entity, id,
2362    fields } }`. Aplica al store via `apply(&ops)`.
2363  - **Seed path** (alta nueva): comportamiento previo.
2364- **`commit_delete(entity, id)`**: emite `LogEntry::Morphism {
2365  name: "ui.delete_record", ops: [Delete { entity, id }] }` + apply.
2366- **Render del form**: título cambia a "Editar customer abc12345"
2367  cuando `editing` matchea; submit label cambia a "Guardar cambios
2368  en customer".
2369- **Render de la lista**: dos columnas nuevas — "id" y "acciones".
2370  Cada fila tiene ✎ (accent color, click → open_edit) y ✕ (rojo,
2371  click → commit_delete). Hover states.
2372
2373Ramificación visible en el event log:
2374```
2375{"kind":"seed","seq":0,"entity":"customer","id":"abc...","data":{"name":"Acme"}}
2376{"kind":"morphism","seq":1,"morphism":"ui.edit_record","ops":[
2377  {"op":"set","path":{"entity":"customer","id":"abc...","field":"name"},
2378   "value":"Acme S.A."}
2379]}
2380{"kind":"morphism","seq":2,"morphism":"ui.delete_record","ops":[
2381  {"op":"delete","entity":"customer","id":"abc..."}
2382]}
2383```
2384Coherente con el modelo de Nakui — todo cambio post-seed pasa por
2385ops dentro de Morphism. `nakui-explorer` muestra estos morphisms
2386con sus ops claros en su timeline.
2387
2388Trade-offs documentados:
2389- **`schema_hash: None`** sigue para los morphism de la UI (legacy/
2390  pre-versioning path) hasta que `Action::Morphism` cargue Manifest
2391  schemas.
2392- **Delete sin confirmación**: 1 click, sin modal. Para MVP es OK
2393  (los records son recuperables vía replay parcial), pero un futuro
2394  iter agregaría confirmación.
2395- **Edit sobreescribe TODOS los campos del form**, no sólo los
2396  cambiados — emite N ops Set, una por field. Adecuado para forms
2397  chicos; para forms con muchos campos optimizar a delta-only.
2398
2399Tests: 3 nuevos (10 totales en nakui-ui):
2400- `value_to_input_text_inverse_of_parse` y
2401  `value_to_input_then_parse_round_trip` — la propiedad fundamental
2402  del pre-llenado: text → parse devuelve el Value original.
2403- `event_log_replay_handles_full_crud_cycle` — E2E del log: escribe
2404  Seed + Morphism(Set ops) + Morphism(Delete op), replay desde cero,
2405  verifica que el store termina vacío (delete fue el último). Verifica
2406  además que un replay parcial (sin el delete) deja los valores
2407  editados.
2408
2409Activación:
2410```sh
2411NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
2412NAKUI_MODULES_DIR=examples/nakui-modules \
2413cargo run -p nakui-ui
2414# Crear un customer, click ✎ en su fila, modificar campos,
2415# "Guardar cambios". Click ✕ en otra fila para borrar.
2416# Cerrar y reabrir: el state persiste con todos los cambios.
2417```
2418
2419### feat(nakui-ui): persistencia con event log + replay al startup
2420Cierra "sin persistencia entre runs" del commit anterior. Cada
2421`SeedEntity` se appendea al `nakui_core::event_log::EventLog` con
2422WAL semantics (log antes que store) y al re-abrir el binario el
2423replay reconstruye el `MemoryStore` desde cero. Cerrar y volver a
2424abrir ya no borra el data.
2425
2426Cambios:
2427- **`MetaUi.event_log: Option<Arc<Mutex<EventLog>>>`** nuevo.
2428  Compartido bajo `Mutex` para que el commit_seed pueda mutar.
2429- **Apertura + replay al startup** (`MetaUi::new`): path por env
2430  `NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`.
2431  `EventLog::open` + `replay_into` reconstruyen el store.
2432  Toast informativo: "log nuevo" o "log X cargado: N evento(s)
2433  replayed".
2434- **WAL en `commit_seed`**: si `event_log.is_some()`, primero
2435  `log.append(LogEntry::Seed { ..., schema_hash: None })`, después
2436  `store.seed`. Si el append falla, cancela toda la operación
2437  (el user reintenta sin haber dejado state inconsistente).
2438- **`schema_hash: None`**: documentado como path "legacy /
2439  pre-versioning" para seeds que no pasan por un Manifest+Executor.
2440  Es el path correcto para alta administrativa vía la metainterfaz
2441  hasta que `Action::Morphism` wireé el Manifest loader.
2442- **Degradación grácil**: si abrir log falla (permisos, disco),
2443  toast con error pero el runtime sigue en modo in-memory.
2444
2445Tests: 1 nuevo E2E `event_log_replay_restores_memory_store` que
2446escribe 2 seeds via `EventLog::append`, re-abre y `replay_into` un
2447store fresh, verifica que ambos records están con sus values
2448correctos. Reproduce el flujo del startup de `MetaUi::new` sin
2449necesitar GPUI. 7 tests verdes en nakui-ui.
2450
2451Activación con persistencia explícita:
2452```sh
2453NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
2454NAKUI_MODULES_DIR=examples/nakui-modules \\
2455cargo run -p nakui-ui
2456# Crear varios records vía el form, cerrar el binario, abrir de
2457# nuevo: los records están.
2458```
2459
2460Limitaciones que **siguen** (próximos iters):
2461- **`Action::Morphism`** sigue como TODO: requiere cargar el
2462  `Manifest` de nakui-core junto al `Module` UI para conocer los
2463  inputs/params declarados y poder llamar `execute_and_log`.
2464- **No hay snapshot/compaction**: el log crece append-only para
2465  siempre. Para repos grandes habría que integrar `Snapshot` de
2466  nakui_core (existe, no se usa todavía).
2467- **No hay UI para borrar/editar** records existentes — sólo alta
2468  vía form. Edit + delete en futuras iteraciones.
2469- **Widget input simple** (sin selection/IME/clipboard) — heredado
2470  de la limitación documentada de `yahweh-widget-text-input`.
2471
2472### feat(nakui-ui): inputs reales con yahweh-widget-text-input + click handlers funcionales
2473Cierra dos limitaciones documentadas en el commit anterior de la
2474metainterfaz: los formularios ahora aceptan teclado real, y los
2475clicks en menús + botones mutan estado correctamente.
2476
2477Cambios:
2478- **Inputs vivos**: cada `FieldSpec` del Form view materializa un
2479  `Entity<TextInput>` (de `yahweh-widget-text-input`) al entrar a la
2480  vista. Los entities se reemplazan al cambiar de view (drop limpio).
2481  El widget soporta: escribir caracteres, Backspace, Enter (Confirmed
2482  event — no usado todavía; el submit va por botón), Escape
2483  (Cancelled). El cursor se renderea como `|` al final.
2484- **Click handlers wired vía `cx.listener`**: menús del sidebar
2485  invocan `select_view`; botones de acción (header de list, submit
2486  de form) invocan `apply_action`. Los handlers tienen acceso real
2487  al `Context<MetaUi>` y mutan el modelo + emiten `cx.notify()`.
2488- **Submit lee texto de los inputs**: `commit_seed` reemplaza el
2489  buffer ad-hoc anterior por `input.read(cx).text()` por cada
2490  field. El value parseado va al `MemoryStore` con su tipo correcto
2491  (text/number/boolean/date).
2492- **Reset de inputs tras submit**: si la acción no tiene `next_view`,
2493  los inputs se vacían (`set_text("")`) para alta consecutiva sin
2494  re-tipear.
2495- **Hover states**: items del sidebar y botones cambian de bg al
2496  pasar el mouse, feedback visual consistente con el resto del
2497  ecosistema yahweh.
2498- **Theme global**: `Theme::install_default(cx)` al inicio (lo
2499  requiere el text_input para sus colores).
2500
2501Wire en Cargo:
2502- Deps nuevas: `yahweh-widget-text-input`, `yahweh-theme` (paths
2503  relativos al monorepo).
2504
2505Limitaciones que **siguen abiertas** (próximos iters):
2506- **`Action::Morphism`** sigue como TODO: requiere cargar el
2507  `Manifest` de nakui-core junto al `Module` UI para conocer los
2508  inputs/params declarados.
2509- **Sin persistencia entre runs**: `MemoryStore` en RAM. Wire con
2510  `EventLog` o `SurrealStore` queda para cuando exista el daemon
2511  Nakui.
2512- **Inputs simples**: el widget no soporta cursor positioning,
2513  selection, copy/paste, IME, multilínea. Para edits serios habrá
2514  que portar `gpui::examples::input` o adoptar `gpui-input` cuando
2515  exista upstream.
2516- **Enter no envía**: el `TextInputEvent::Confirmed` que emite el
2517  widget no está suscrito todavía; el submit va por click. Trivial
2518  de wirear si lo necesitamos.
2519
2520Tests: los 6 unit del runtime siguen verdes (parse_field_value para
2521los 5 kinds, lookup_field nested, render_value). El comportamiento
2522visual requiere correr el binario con `cargo run -p nakui-ui` y
2523probar a mano — GPUI no provee harness de UI testing en CI hoy.
2524
2525Activación full:
2526```sh
2527NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
2528# Click en un menú → carga vista. Click en "Nuevo" → form.
2529# Tipear en cada campo → ver el `|` al final. Click "Crear customer"
2530# → record aparece en la lista.
2531```
2532
2533### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar
2534Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
2535del event log" a **plataforma ERP con UI dirigida por datos**. Cada
2536módulo de negocio se declara como un `module.json` (sin código Rust
2537nuevo) y el runtime GPUI lo carga dinámicamente: sidebar de menús,
2538listas con columnas configurables, formularios de alta.
2539
2540Tres entregables:
2541
2542**1) Crate nuevo `nakui-ui-schema`** (datos puros, ~250 LOC + 200
2543LOC tests):
2544- `Module { id, label, entities, menu, views }`.
2545- `View::List { entity, columns, actions, search_in }` o
2546  `View::Form { entity, fields, on_submit }`.
2547- `FieldSpec { name, label, kind, default, required, help }` con
2548  `FieldKind = Text|Multiline|Number|Boolean|Date`.
2549- `Action::OpenView | SeedEntity | Morphism` — el runtime las
2550  dispara desde botones / submits.
2551- `Module::from_path` parsea un JSON; `Module::validate` chequea que
2552  cada `MenuItem.view` exista en `views`.
2553- `load_modules_from_dir(dir)` busca `dir/<modulo>/module.json`,
2554  parsea, valida, detecta IDs duplicados, devuelve ordenado.
2555- 6 tests unit + 4 integration (los 6 demos cargan limpio, todos
2556  tienen list+form, kinds reconocidos, validate pasa).
2557
2558**2) Crate nuevo `nakui-ui`** (binario GPUI, ~700 LOC + 100 LOC tests):
2559- Carga módulos desde `NAKUI_MODULES_DIR` (default `./nakui-modules`).
2560- Sidebar con módulos + sus menús; click en menu cambia la vista activa.
2561- **List view**: tabla de instancias del entity con columnas
2562  weighted (header de columnas + filas + id corto).
2563- **Form view**: campos labeled + botón submit que dispara la action
2564  declarada (`SeedEntity` mete el record al `MemoryStore`
2565  in-process; `Morphism` queda como TODO hasta integrar el manifest
2566  loader nakui-core).
2567- `MemoryStore` compartido entre todas las vistas (Arc<Mutex>); el
2568  cambio en un módulo se refleja en otro inmediato.
2569- Toast + error banner para feedback.
2570- 6 tests unit (parse_field_value para los 5 kinds, lookup_field
2571  nested, render_value).
2572
2573**3) 6 módulos demo** en `examples/nakui-modules/` que cubren un
2574ERP estándar:
2575- **customers**: nombre, email, teléfono, activo, límite de
2576  crédito, notas.
2577- **products**: SKU, nombre, categoría, precio, stock, activo.
2578- **suppliers**: razón social, ID fiscal, contacto, email,
2579  teléfono, términos de pago.
2580- **inventory_movements**: fecha, tipo (in/out/adjustment), SKU
2581  producto, cantidad, costo unitario, motivo, doc. referencia.
2582- **sales_orders**: número, cliente, emisión, vencimiento,
2583  estado, subtotal, impuestos, total, notas.
2584- **invoices**: número, cliente, emisión, vencimiento, subtotal,
2585  impuestos, total, pagado, estado, moneda, orden referenciada.
2586
2587Cada módulo tiene su `list` (catálogo) + `form` (alta), con search
2588field y columns weighted. Los 6 cubren un setup de ERP de ventas
2589chico funcional para demo.
2590
2591Filosofía documentada:
2592- **UI como datos**: agregar un módulo = escribir un JSON, no
2593  recompilar el binario.
2594- **Persistencia universal**: el runtime conecta cada vista al
2595  `nakui_core::store::Store`; cambiar de MemoryStore a SurrealStore
2596  no toca los module.json.
2597- **Schema primero, semántica después**: `nakui-ui-schema` sólo
2598  define la forma; validación de referencias rotas (entity inexistente,
2599  morphism faltante) vive en el runtime.
2600
2601Activación:
2602```sh
2603NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
2604```
2605
2606Limitaciones conocidas (próximas iteraciones):
2607- **Inputs sin teclado**: GPUI no incluye text input; los forms
2608  muestran los `default` del schema y el submit usa esos. Próximo
2609  iter: integración con `yahweh-widget-text-input`.
2610- **Click handlers no wired**: GPUI necesita pasar `Entity<MetaUi>`
2611  a los handlers para mutar estado; refactor con `cx.listener` +
2612  weak refs queda para el próximo iter. Hoy la navegación es
2613  visual; el código de mutación sí funciona via API programática
2614  (los tests lo cubren).
2615- **Acción `Morphism`**: pendiente de cargar el `Manifest` de
2616  nakui-core junto con el `Module` UI para wirear `execute_and_log`.
2617- **Sin persistencia entre runs**: el `MemoryStore` se pierde al
2618  cerrar. Wire con `EventLog` o `SurrealStore` queda para cuando
2619  el daemon Nakui exista.
2620
2621Tests: 16 totales nuevos (10 schema + 6 runtime). 100% verde.
2622
2623Lo que esto desbloquea: cualquiera puede escribir un `module.json`
2624para su dominio (pacientes médicos, alumnos de escuela,
2625reservaciones de hotel) y aparece en la UI sin tocar Rust ni
2626recompilar. La forma de extender Nakui dejó de ser "agregar código
2627al ERP" y pasó a ser "escribir el contrato del módulo".
2628
2629### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
2630Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone
2631`nakui-explorer` (paralelo a `nouser-explorer`) que renderea el
2632event log de un repo Nakui: timeline scrollable de seeds + morphisms
2633con sus parámetros, breakdown por entity type, polling cada 2s para
2634detectar nuevos eventos appended sin restart del explorer.
2635
2636Diseño:
2637- Lee directamente el archivo `.jsonl` del `nakui_core::event_log::EventLog`.
2638  Path por env `NAKUI_EVENT_LOG`, default `nakui.jsonl` en pwd.
2639- Sin discovery vía broker brahman porque nakui hoy es CLI/library/
2640  demos, no daemon. Cuando se daemonice, sustituir el lector de
2641  archivo por un sidecar consumer (mismo patrón que nouser-explorer
2642  actualmente usa).
2643
2644UI:
2645- **Header**: path del log, count total + breakdown seeds/morphisms,
2646  tiempo del último reload en ms.
2647- **Breakdown line**: top 5 buckets por frecuencia (entities + nombres
2648  de morphisms con prefijo `→`).
2649- **Timeline**: tarjetas color-coded por kind (azul=seed,
2650  verde=morphism). Cada tarjeta muestra `#seq`, kind, entity/morphism
2651  name, id corto (8 hex), preview del data/params (80 chars), schema
2652  hash corto (8 hex) o `(legacy)` si pre-versioning. Mostradas
2653  más-recientes-primero, hasta 200 visibles (suficiente para
2654  navegación; sin scroll virtualizado por ahora).
2655- **Error banner**: si la lectura falla (archivo inexistente o
2656  corrupto), banner rojo con el motivo. El explorer NO crashea —
2657  sigue intentando cada 2s.
2658
2659Wire en workspace:
2660- Nuevo `crates/apps/nakui-explorer/` agregado a `[workspace] members`.
2661- Deps mínimas: `nakui-core` (para EventLog + LogEntry), `gpui`,
2662  `serde_json`, `uuid` (con feature serde para parsear los IDs).
2663- Sin deps de brahman por ahora (Nakui standalone).
2664
2665Tests: 7 unitarios en `tests` mod del bin:
2666- `load_log_returns_all_entries_in_order` — cargar un .jsonl
2667  generado a mano, asserta que devuelve 5 entries con seqs 0..4
2668  contiguous.
2669- `breakdown_counts_seeds_morphisms_and_buckets` — verifica el
2670  conteo (3 seeds + 2 morphisms) y los buckets esperados.
2671- `load_missing_file_yields_empty_not_error` — archivo inexistente
2672  devuelve `[]` sin error (delegado al contrato de `EventLog::open`).
2673- `preview_value_truncates_long_strings` y `_keeps_short_strings_intact`.
2674- `short_uuid_takes_first_8_chars` y `short_hash_takes_first_4_bytes_hex`.
2675
2676Activación:
2677```sh
2678NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer
2679```
2680
2681Estado del CHANGELOG global tras este commit: cero pendientes
2682fundamentados activos. Lo único que queda es `minga-vfs` (FUSE,
2683explícitamente diferido por el usuario) y mejoras nice-to-have
2684(cobertura adicional per-lenguaje, daemon-ización de nakui para
2685sidecar discovery).
2686
2687### feat(minga-core): α-hashing per-language para Python, TypeScript, JavaScript, Go
2688Cierra el último pendiente fundamentado del CHANGELOG. Cada lenguaje
2689soportado por `minga` tiene ahora su propio profile α-equivalente —
2690dos versiones del mismo programa que difieren sólo en nombres de
2691variables ligadas producen el mismo hash, no importa el lenguaje.
2692Refactorings tipo "rename variable" no inflan el storage del repo
2693en ningún dialecto.
2694
2695Refactor de `alpha.rs` (639 LOC) a módulo `alpha/`:
2696- **`alpha/common.rs`**: primitives compartidos (TAG_*, write_kind_and_field,
2697  emit_leaf_marker, emit_binder_body, emit_identifier_ref, push_identifier_name).
2698  Garantiza que el formato wire del hash sea bit-equivalente entre
2699  todos los profiles.
2700- **`alpha/rust.rs`**: la lógica de Rust (movida desde alpha.rs sin
2701  cambios funcionales).
2702- **`alpha/python.rs`**: nuevo.
2703- **`alpha/ecmascript.rs`**: nuevo (cubre TypeScript + JavaScript;
2704  comparten la mayoría de los kinds).
2705- **`alpha/go.rs`**: nuevo.
2706- **`alpha/mod.rs`**: re-exporta `hash_node_alpha` (Rust legacy) +
2707  expone `hash_alpha_with(dialect, node)` que despacha al profile
2708  correspondiente.
2709
2710Cobertura per-language:
2711
2712**Python** (`def`, `lambda`, `for`, comprehensions, `with`):
2713- `function_definition` y `lambda`: parámetros (incluyendo
2714  typed_parameter, default_parameter, *args, **kwargs) introducen
2715  binders al body. El nombre de la función NO es α-anónimo.
2716- `for_statement`: el `left` (identifier o tuple) introduce
2717  binder(es) al body.
2718- `list_comprehension`, `set_comprehension`, `dictionary_comprehension`,
2719  `generator_expression`: cada `for_in_clause` añade binders que
2720  viven en el body + clauses siguientes (semántica de scope
2721  incremental de Python).
2722- `with_statement`: `as` introduce binder al body (recursando en
2723  `as_pattern_target` para llegar al identifier).
2724
2725**ECMAScript** (TS + JS):
2726- `function_declaration`, `function_expression`, `method_definition`,
2727  `generator_function_*`: parameters → body. Soporta TS
2728  `required_parameter` y `optional_parameter` (`x: number`,
2729  `x?: number`).
2730- `arrow_function`: tanto `(x, y) => body` como shorthand `x => body`.
2731- `statement_block`: `lexical_declaration` (let/const) y
2732  `variable_declaration` (var) introducen binders al resto del block.
2733- `for_in_statement` (cubre `for-of` y `for-in`): `left` → body.
2734- `for_statement` (C-style): initializer (lexical decl) introduce
2735  binders al condition + increment + body.
2736- `catch_clause`: parameter → body.
2737
2738**Go**:
2739- `function_declaration`, `method_declaration`, `func_literal` (closure):
2740  `parameter_list` → body. `parameter_declaration` con varios names
2741  agrupa varios binders bajo un mismo tipo (`a, b int`).
2742- `block`: `short_var_declaration` (`x := ...`) introduce binders
2743  al resto.
2744- `for_statement` con `range_clause` (`for k, v := range m`): los
2745  identifiers del `left` son binders al body.
2746- `for_statement` con `for_clause` (C-style): initializer → body.
2747- `if_statement` con `initializer` (`if x := init(); x > 0`):
2748  binders viven en condition + consequence + alternative.
2749
2750API:
2751- `hash_alpha_with(Dialect, &SemanticNode) -> ContentHash` —
2752  despacho per-dialect.
2753- `hash_node_alpha(&SemanticNode) -> ContentHash` — alias histórico
2754  asume Rust (back-compat).
2755
2756Tests: 26 nuevos en `tests/alpha_polyglot.rs`:
2757- Python (9): def rename, lambda rename, for-loop rename, list comp,
2758  nested comp, with rename, function name matters, iterable name
2759  matters, sanity negativo (operación distinta → hash distinto).
2760- JS/TS (9): function rename, function name matters, arrow rename,
2761  arrow shorthand rename, let/const rename, for-of rename, classic
2762  for rename, catch rename, TS typed param rename, TS type matters.
2763- Go (6): function rename, function name matters, short var decl
2764  rename, range_clause rename, if-init rename, func_literal closure
2765  rename.
2766- Cross-language (1): mismos shapes en lenguajes distintos
2767  producen hashes distintos (sanity para evitar colisiones).
2768
2769141 tests verdes en minga-core (115 antes; +26 polyglot). Refactor
2770sin regresión: 36 α-Rust tests siguen pasando.
2771
2772Pendientes que quedan en Minga (orden de prioridad):
2773- `minga-vfs` FUSE (proyecto independiente, scope grande).
2774- Cobertura adicional por-lenguaje: Python class, JS destructuring,
2775  Go type_switch, etc. — cada uno pequeño, no urgente.
2776
2777### feat(minga-core): cierre del α-hashing de Rust — if let, while let, let-else, or-pattern, let-chains
2778Cierra los 5 pendientes documentados en `alpha.rs`. El hash
2779α-equivalente ahora es estable bajo renombre de TODOS los binders
2780de Rust, no sólo los del MVP (parámetros, let, for, match arms).
2781
2782Pendientes cerrados:
2783- **`if let X = expr { ... }`**: `if_expression` detecta
2784  `let_condition` en su `condition`, recolecta los binders del
2785  pattern, los propaga al `consequence`. El `alternative` (else)
2786  NO los ve.
2787- **`while let X = expr { ... }`**: simétrico al if-let, propaga al
2788  `body`. El `condition` mismo se evalúa con scope previo (los
2789  binders todavía no existen).
2790- **`let-else`**: `let_declaration` con campo `alternative`. El
2791  alternative se procesa con el scope ANTES de los binders (ya
2792  funcionaba: `feed_let` llama `feed` para no-pattern children con
2793  el scope actual; `feed_block` extiende el scope DESPUÉS de
2794  `feed_let`).
2795- **`or_pattern`**: en `pat1 | pat2` (Rust enforcement: ambos lados
2796  introducen los mismos binders). Para emit, recorremos cada lado
2797  con `feed_pattern`. Para collect, sólo el primer lado — iterar
2798  todos duplicaría binders y rompería los índices de Bruijn.
2799- **let-chains** (`if let X = a && let Y = b { ... }`): el
2800  `collect_let_condition_binders` recursa en el árbol del condition,
2801  capturando todos los `let_condition` (vivan dentro de
2802  `binary_expression` u otros nodos). Ambos binders quedan en scope
2803  del consequence.
2804
2805Helper nuevo: `feed_let_condition` para que el `pattern` del
2806let_condition pase por `feed_pattern` (que distingue binders vs
2807constructors). Sin esto, los identifiers del pattern se hasheaban
2808como variables libres y `Some(x)` ≠ `Some(y)` aún teniendo el
2809mismo significado.
2810
2811Tests: 6 nuevos en `tests/alpha_invariants.rs`:
2812- `alpha_if_let_binder_rename_invariant`
2813- `alpha_if_let_else_does_not_see_binder` (sanity)
2814- `alpha_while_let_binder_rename_invariant`
2815- `alpha_let_else_binder_rename_invariant`
2816- `alpha_or_pattern_binder_rename_invariant`
2817- `alpha_let_chain_binders_propagate_to_consequence`
2818- `alpha_if_let_does_not_collide_with_unrelated_program` (negativo:
2819  programas distintos NO deben dar el mismo hash)
2820
282136 tests α verdes (eran 30). 115 tests totales en minga-core.
2822
2823Lo que esto significa: el hash α-equivalente de Rust en minga es
2824**completo** — cubre todos los constructos del lenguaje que
2825introducen bindings. Dos versiones del mismo programa que difieren
2826sólo en nombres de variables (incluyendo en `if let`, `while let`,
2827`or-pattern`, etc.) producen el mismo hash y por tanto la misma
2828identidad CAS. Refactorings del tipo "rename variable" no inflan
2829el storage del repo.
2830
2831Pendientes futuros:
2832- α-hashing per-language (Python: def/lambda/comprehensions; TS/JS:
2833  function/arrow/destructuring; Go: func/closure). Cada uno
2834  requiere conocimiento profundo de la gramática y tests
2835  exhaustivos. Plantilla genérica no aplica.
2836
2837### feat(minga): multi-lenguaje en parser — Python, TypeScript, JavaScript, Go
2838Minga deja de ser Rust-only. Cualquiera de los cinco dialectos
2839(Rust + 4 nuevos) se ingresa al CAS por su AST normalizado, hashea
2840estructuralmente, sincroniza por DHT como cualquier nodo. La
2841auto-detección por extensión hace que `minga ingest archivo.py` o
2842`.ts` o `.go` "simplemente funcione".
2843
2844API nueva en `minga_core::parse`:
2845- Funciones por dialecto (~6 LOC c/u sobre el `parse_with` común):
2846  `python`, `typescript`, `javascript`, `go`. Más la `rust` existente.
2847- Enum `Dialect` con `parse(source) -> Result<SemanticNode>` y
2848  `name() -> &'static str` para logging.
2849- `detect_by_extension(ext) -> Option<Dialect>`: mapea `rs`/`py`/
2850  `pyi`/`ts`/`js`/`mjs`/`cjs`/`go` (case-insensitive). `None` para
2851  extensiones desconocidas — el caller decide si es error o se
2852  ignora silente.
2853
2854Wire en `minga-cli`:
2855- `cmd_ingest` deja de hardcodear `parse::rust` — usa
2856  `detect_dialect(file)?.parse(...)`. Acepta `.py`, `.ts`, `.js`,
2857  `.go` además de `.rs`.
2858- `initial_scan` y `cmd_watch` cambian `is_rs_file` → `is_supported_source`
2859  para incluir todas las extensiones soportadas en el filtro.
2860- `CliError::UnsupportedLanguage { path, extension }` nuevo, con
2861  mensaje que lista las extensiones reconocidas.
2862
2863Notas sobre hashing:
2864- El AST normalizado (`SemanticNode`) descarta whitespace y
2865  comentarios — propiedad universal de tree-sitter (extras). Misma
2866  lógica para los 5 dialectos.
2867- Hashing **estructural** (`cas::hash_node`) funciona para todos:
2868  dos textos semánticamente equivalentes-por-estructura producen el
2869  mismo hash. NO α-equivalente (las variables ligadas distinguen).
2870- Hashing **α-equivalente** (`alpha::hash_node_alpha`) sigue siendo
2871  Rust-only: cada lenguaje tiene reglas distintas para qué es
2872  binder vs. constructor (def/lambda en Python, arrow functions en
2873  TS/JS, func + closures en Go). Implementación per-language queda
2874  como work futuro — requiere conocimiento profundo de cada
2875  gramática y no se plantilla genéricamente.
2876- Sanity test `structural_hash_distinguishes_languages` verifica
2877  que `x = 1` parseado como Python ≠ parseado como JavaScript: las
2878  gramáticas no comparten kinds y los hashes salen distintos.
2879  Importante para evitar colisiones cuando el mismo source se
2880  ingresa bajo dialectos distintos.
2881
2882Deps nuevas (workspace + minga-core):
2883- `tree-sitter-python = "0.23"`
2884- `tree-sitter-typescript = "0.23"` (sólo el modo `LANGUAGE_TYPESCRIPT`,
2885  no TSX — bumpear a TSX es agregar otro dialecto cuando se necesite).
2886- `tree-sitter-javascript = "0.23"`
2887- `tree-sitter-go = "0.23"`
2888
2889Tests:
2890- 9 nuevos en `parse::tests`: parse básico para los 5 dialectos
2891  (Python con type hints, TS con tipos, JS sin tipos, Go con
2892  package declaration), `detect_by_extension` canonical +
2893  case-insensitive, `dialect_name`, `structural_hash_distinguishes_languages`.
2894- 108 tests verdes en minga-core (39 → 48 unit + integration tests
2895  pre-existentes intactos).
2896- 10 tests verdes en minga-cli (sin regresión en el path Rust;
2897  el refactor a `detect_dialect`/`is_supported_source` no rompe
2898  nada).
2899
2900Pendientes futuros del changelog:
2901- α-hashing per-language (Python: def/lambda/comprehensions;
2902  TS/JS: function/arrow/destructuring; Go: func/closure). Trabajo
2903  profundo, scope independiente.
2904- α-Rust pendientes documentados en `alpha.rs`: `if let`,
2905  `while let`, `let-else`, let-chains, `or_pattern` con bindings.
2906
2907### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico
2908Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar
2909la keypair libp2p de un nodo cambiaba su `peer_id`, lo que
2910invalidaba todas las allowlists/denylists remotas que lo
2911referenciaban. Imposible rotar sin coordinar con todos los pares.
2912
2913Solución: separar **identity master** (Ed25519 persistente forever,
2914identifica al nodo como entidad lógica) de **session libp2p**
2915(Ed25519 efímera, rotable). El master firma certs de session con
2916expiración. La política de admisión se evalúa contra el
2917`master_peer_id` del cert — el session peer_id puede cambiar
2918libremente sin tocar las allowlists.
2919
2920API nueva en `brahman_handshake::identity`:
2921- `Identity::from_keypair(master)` — wrapper sobre la master kp.
2922- `Identity::master_peer_id()` — el peer_id estable del nodo.
2923- `Identity::issue_session_cert(session_kp, ttl) -> SessionCert` —
2924  firma un cert que vincula session_pubkey + expires_at_ms.
2925- `SessionCert::verify()` — chequea versión, firma criptográfica,
2926  no expiración. Devuelve `(master_peer_id, session_peer_id)`.
2927- `SessionCert::verify_against_session(expected_session_pk)` — verify
2928  + exige que el cert vincule esa session pubkey (previene reuso de
2929  certs ajenos con keypairs distintas).
2930- `CertError` tipado: `UnknownVersion`, `DecodeMaster`,
2931  `DecodeSession`, `InvalidSignature`, `Expired`, `SessionMismatch`,
2932  `Sign`.
2933- `DEFAULT_SESSION_TTL = 24h`.
2934
2935Wire:
2936- `Hello.identity_cert: Option<SessionCert>` agregado (default None,
2937  back-compat).
2938- `Client::connect_with_stream_signed_with_cert(stream, card, wit,
2939  session_kp, cert)` — variante que adjunta el cert.
2940- `network::connect_libp2p_with_cert(net, peer, card, wit,
2941  session_kp, cert)` — paralelo a `connect_libp2p`.
2942
2943Server (`do_handshake`):
2944- Nuevo paso ANTES del policy gate: si `Hello.identity_cert.is_some()`,
2945  se verifica con `verify_against_session(&hello.signature.public_key)`.
2946  El `logical_peer` que se evalúa contra la policy es el
2947  `master_peer_id` derivado, NO el session peer_id.
2948- Sin cert (path Fase 3): `logical_peer = expected_peer` (compat).
2949- Si el cert es inválido (firma rota, expirado, session mismatch),
2950  rechazo con `Unauthorized` antes de evaluar policy.
2951- Migración gradual: clientes sin cert siguen funcionando contra
2952  servers con policy basada en session peer_ids.
2953
2954Canonicalización del payload firmado:
2955```
2956[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms]
2957```
2958`SESSION_CERT_VERSION = 1` documenta el esquema; cualquier cambio
2959fuerza bump (clientes viejos no validan certs nuevos).
2960
2961Sobre el swarm-level deny:
2962- El `block_list` del swarm sigue operando con session peer_ids
2963  (Noise sólo conoce eso). Si la operatoria lista master_peer_ids
2964  en deny, el handshake-level gate los para; el swarm-level no.
2965  El operador elige granularity: listar masters = robust a
2966  rotaciones; listar sessions = rechazo más temprano.
2967
2968Tests: 8 unit en `identity::tests`:
2969- `issue_and_verify_cert` — roundtrip básico, peer_ids derivados.
2970- `verify_against_session_admits_matching` y
2971  `_rejects_mismatch` — el cert vincula 1 sola session pubkey.
2972- `cert_with_zero_ttl_is_expired` — expiración chequeada con tiempo
2973  real.
2974- `tampered_signature_rejected` y `tampered_expires_at_rejected` —
2975  cualquier mutación del cert post-firma falla.
2976- `unknown_version_rejected` — schema versionado defensivamente.
2977- `rotated_session_with_same_master_yields_same_master_peer_id` —
2978  la propiedad fundamental: rotar session NO cambia master_peer_id.
2979
2980Plus 1 E2E definitivo en `network_libp2p.rs`:
2981`identity_cert_allows_session_rotation_without_policy_change`.
2982- A configura `policy = allowlist[B.master_peer_id]` (master, no
2983  session).
2984- B se conecta con session1 + cert(master, session1) → admitido.
2985  Sesión registrada, farewell limpio.
2986- B "rota": genera session2 ≠ session1, mismo master, emite cert2.
2987- B se conecta con session2 + cert2 → admitido también, **sin que
2988  A toque su allowlist**.
2989- Sanity: una session sin cert (cuyo session_peer NO está en allow)
2990  es rechazada.
2991
299240 tests verdes en brahman-handshake + brahman-net (24 unit
2993incluyendo identity + 7 handshake + 3 discovery + 6 libp2p
2994incluyendo rotation E2E). Ningún regreso.
2995
2996Wire en Arje queda como follow-up: ente-zero hoy es server-only y
2997no necesita identity (su keypair libp2p ya es estable vía
2998keypair_store). Cuando algún módulo de Arje haga conexiones
2999salientes con cert, se cargará la identity master separada de la
3000session vía nueva env `BRAHMAN_IDENTITY_PATH`. La API ya está
3001lista.
3002
3003### feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p
3004Optimización de seguridad: la denylist ya no espera al handshake
3005brahman para rechazar — ahora se proyecta al `block_list` behaviour
3006del swarm libp2p. Conexiones desde peers baneados son rechazadas
3007**antes del Noise handshake**, ahorrando el round-trip TCP+Noise
3008por cada intento denegado.
3009
3010Wire de bajo nivel (`brahman-net`):
3011- Nuevo behaviour `block_list: allow_block_list::Behaviour<BlockedPeers>`
3012  añadido al `BrahmanBehaviour` derivado. Vive junto a `stream`,
3013  `kad`, `identify`. Default vacío al construir.
3014- Nuevos comandos `BlockPeer(PeerId)` y `UnblockPeer(PeerId)` en el
3015  enum interno + handlers que llaman
3016  `swarm.behaviour_mut().block_list.{block_peer,unblock_peer}`.
3017- API pública: `BrahmanNet::block_peer(peer)` y
3018  `BrahmanNet::unblock_peer(peer)`. Idempotentes.
3019- Dep nueva: `libp2p-allow-block-list = "0.6"` (sub-crate, no es
3020  feature de `libp2p` en 0.56).
3021
3022Wire en la política (`brahman_handshake::peer_policy`):
3023- `PeerPolicy` gana campo opcional `net: Arc<RwLock<Option<Arc<BrahmanNet>>>>`.
3024  Default `None` para preservar callers existentes.
3025- Nuevo método `attach_to_net(net: Arc<BrahmanNet>)`:
3026  - Sincronización inicial: itera la deny actual y llama
3027    `net.block_peer(p)` por cada uno.
3028  - Guarda el net para diff-sync en cada `reload`.
3029- `reload()` extendido: snapshot de `prev_deny` ANTES de mutar el
3030  inner. Tras la mutación, llama `sync_deny_to_swarm(prev, new)`
3031  que aplica `block_peer` por cada added y `unblock_peer` por cada
3032  removed.
3033- Atomicidad preservada: si un archivo falla al parsear, el sync
3034  no ocurre y la versión anterior persiste tanto en la policy
3035  como en el block_list del swarm.
3036
3037Wire en Arje (`ente-zero`):
3038- Tras setup_brahman_net + setup_brahman_policy, si AMBOS están
3039  presentes se llama `policy.attach_to_net(net.clone())` con un log
3040  informativo. Sin policy o sin net, no hay attach (modo abierto
3041  o solo gate-level deny).
3042
3043Tests: 1 nuevo E2E en `network_libp2p.rs`:
3044`swarm_level_deny_blocks_before_noise`. A configura policy con
3045deny de un peer + attach_to_net. Cliente baneado intenta
3046`connect_libp2p`; en lugar del `HandshakeError::Unauthorized` que
3047recibíamos antes (que requería completar Noise primero), ahora
3048falla con error de transporte/stream (o timeout, según timing) —
3049el dial nunca completa porque el swarm rechaza la conexión.
3050
30515 tests verdes en `network_libp2p.rs` (roundtrip, mismatched signing,
3052allowlist, denylist handshake-level, denylist swarm-level). 31 tests
3053totales en brahman-handshake + brahman-net. Sin regresión en
3054ente-zero.
3055
3056Trade-offs:
3057- **Más eficiente** contra DoS: un atacante que prueba miles de
3058  peer_ids no consume CPU del Noise handshake.
3059- **Misma fuente de verdad**: la denylist sigue viviendo en
3060  `PeerPolicy` (un solo archivo, hot-reloadable). El swarm es un
3061  cache derivado que se actualiza vía diff. No hay drift posible —
3062  cada reload re-sincroniza atómicamente.
3063- **El handshake-level gate sigue activo** como segunda línea: si
3064  por alguna razón un peer baneado pasa el block_list (race entre
3065  reload y nueva conexión, o bug del crate), el handshake brahman
3066  igual lo rechaza con `Unauthorized`. Defensa en profundidad.
3067
3068Pendientes futuros del changelog:
3069- Rotación de keypair sin perder peer_id (multi-key identity).
3070
3071### feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers
3072Consolida `PeerAllowlist` + nueva `PeerDenylist` en un único
3073`PeerPolicy` con allow + deny + hot reload vía `notify`. Cubre los
3074dos pendientes documentados en el commit anterior y simplifica la
3075API hacia un sólo punto de entrada.
3076
3077API consolidada en `brahman_handshake::peer_policy`:
3078- `PeerPolicy::open()` — todo permitido (default).
3079- `PeerPolicy::from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>)`
3080  — política inline para tests.
3081- `PeerPolicy::from_files(allow_path?, deny_path?)` — carga ambos
3082  archivos opcionales.
3083- `PeerPolicy::evaluate(peer) -> Decision` — `Admit |
3084  DeniedByDenylist | NotInAllowlist`. Decision lleva su `reason()`
3085  para logging consistente.
3086- `PeerPolicy::reload()` — recarga atómica desde los paths
3087  asociados. **Si un archivo falla, conserva la versión anterior**
3088  (un typo no debe tirar al Init en modo inseguro).
3089- `PeerPolicy::spawn_watcher() -> JoinHandle` — vigila los
3090  archivos vía `notify`, debounce 250ms (coalesce de los varios
3091  eventos típicos de un save), recarga atómica al detectar cambio.
3092
3093Orden de evaluación (deny-first):
30941. Si `peer ∈ denylist` → `DeniedByDenylist`.
30952. Si hay allowlist y `peer ∉ allowlist` → `NotInAllowlist`.
30963. Resto → `Admit`.
3097
3098Esto significa que **deny gana sobre allow**: un peer en ambas listas
3099es rechazado. Diseño explícito para que la denylist sea la primitiva
3100de "kill switch" — agregar un peer al deny lo banea inmediatamente
3101sin importar dónde más esté listado.
3102
3103Watcher: vigila el **directorio padre** del archivo, no el archivo
3104mismo. Razón: editores típicos hacen rename-and-replace (escriben
3105a tmp y rename al destino), lo que rompe el watch del archivo pero
3106no el del dir. Filtra eventos por path al procesar.
3107
3108Wire en server:
3109- `ServerConfig.allowlist` → `ServerConfig.policy: Option<PeerPolicy>`
3110  (breaking rename, scope local al monorepo). Gate en `do_handshake`
3111  llama `policy.evaluate(&peer)` y usa `decision.reason()` para el
3112  mensaje de error tipado.
3113
3114Wire en Arje (`ente-zero`):
3115- Nueva env `BRAHMAN_PEER_DENYLIST` complementa
3116  `BRAHMAN_PEER_ALLOWLIST`. Cualquiera (o ambas) activa la política.
3117- `setup_brahman_policy()` carga + arranca watcher. Devuelve
3118  `(policy, JoinHandle)`; el handle se guarda en main para que el
3119  thread no se aborte.
3120- Failure modes degradan a "modo abierto" (sin política) con log,
3121  preservando la doctrina PID 1.
3122
3123Activación end-to-end con todas las capas activas:
3124```sh
3125BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \
3126BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \
3127BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \
3128ente-zero
3129# El operador puede editar deny.txt en caliente y la nueva regla
3130# entra en efecto en ~250ms sin restart del Init.
3131```
3132
3133Tests: 10 unit en `peer_policy::tests`:
3134- `open_admits_anyone`, `allow_only_admits_listed`,
3135  `deny_overrides_open`, `deny_overrides_allow` (deny-first
3136  semantics).
3137- `from_files_with_both_lists`, `from_files_only_deny`,
3138  `invalid_file_rejected_at_load`.
3139- `reload_picks_up_changes` — manualmente recarga y verifica.
3140- `reload_failure_preserves_previous_state` — invariante de
3141  seguridad: archivo roto NO baja la política activa.
3142- `watcher_reloads_on_file_change` — E2E del watcher con notify
3143  real: muta archivo, espera < debounce + margen, verifica que
3144  la política refleja el cambio sin haber llamado reload manualmente.
3145
3146Plus 1 E2E nuevo en `network_libp2p.rs`:
3147`libp2p_handshake_denylist_blocks_listed_peer` — A configura
3148`policy = PeerPolicy::from_sets(None, [banned_peer])`. Cliente
3149con keypair baneada es rechazado; cliente con keypair distinta
3150pasa el handshake.
3151
315230 tests verdes en brahman-handshake (16 unit + 7 handshake + 3
3153discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin
3154regresión en ente-zero.
3155
3156Lo que cierra esta entrega:
3157- Política completa de admisión: open / allow-only / deny-only /
3158  allow+deny.
3159- Hot reload sin restart del Init — el operador puede banear/admitir
3160  peers en caliente editando archivos.
3161- Atomicidad: la recarga es del paquete `(allow, deny)` completo, no
3162  de cada lista por separado. No hay momento donde una lista esté
3163  vieja y la otra nueva.
3164- Resiliencia: errores de parseo NO bajan la política activa.
3165
3166Pendientes futuros del changelog:
3167- Aplicar la política a nivel de swarm vía `libp2p_allow_block_list::
3168  Behaviour` (rechazar ANTES del Noise handshake, ahorrar el
3169  round-trip TCP+Noise por intento denegado).
3170- Rotación de keypair sin perder peer_id (multi-key identity).
3171
3172### feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p
3173Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora
3174cualquier peer con keypair Ed25519 válida pasaba el handshake remoto;
3175con allowlist activa, sólo los peers explícitamente listados. Aplica
3176únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED
3177del kernel, que es autenticación de proceso local, no de red.
3178
3179API nueva en `brahman_handshake::peer_allowlist`:
3180- `PeerAllowlist::from_iter([peer_id, ...])` para tests/inline.
3181- `PeerAllowlist::from_file(path)` parsea texto plano: un PeerId
3182  base58 por línea, `#` para comentarios (línea entera o inline),
3183  líneas vacías ignoradas. Errores de parseo incluyen número de
3184  línea para debug rápido.
3185- `is_allowed(peer)`, `len()`, `is_empty()`, `iter()`.
3186- `AllowlistError { Io, InvalidPeerId }`.
3187
3188Wire en el server:
3189- `ServerConfig.allowlist: Option<PeerAllowlist>`. `None` = modo
3190  abierto (compat con todo lo anterior). `Some` = sólo los listados.
3191- Gate en `do_handshake` ANTES de la verificación de firma — la
3192  comparación O(log n) en BTreeSet es más barata que crypto, así
3193  que rechazamos peers inválidos antes de gastar ciclos. Se devuelve
3194  `HandshakeError::Unauthorized("peer X no está en la allowlist")`.
3195
3196Wire en Arje (`ente-zero`):
3197- Nueva env var `BRAHMAN_PEER_ALLOWLIST` apuntando a un archivo.
3198- `setup_brahman_allowlist()` carga al startup; degrada a `None`
3199  (modo abierto) si el archivo falla, consistente con la doctrina
3200  PID 1 de no romper por subsistemas opcionales.
3201
3202Ejemplo de archivo de allowlist:
3203```text
3204# Peers permitidos en la malla brahman de prod-eu-1
3205# Generados con: ente-zero (peer_id loggeado al arrancar)
320612D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz
320712D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux  # operador 2
3208```
3209
3210Activación end-to-end:
3211```sh
3212BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\
3213BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\
3214ente-zero
3215```
3216
3217Tests:
3218- 6 unit en `peer_allowlist::tests`: from_iter, parse limpio, parse
3219  con comentarios inline, parse rechaza PeerId inválido (y reporta
3220  número de línea), I/O error en archivo faltante, empty list
3221  rechaza todo.
3222- 1 E2E en `network_libp2p.rs`:
3223  `libp2p_handshake_allowlist_admits_listed_rejects_others`. A
3224  configura `allowlist = [allowed_peer]`. Cliente con keypair
3225  permitida pasa el handshake (sesión registrada, farewell limpio).
3226  Segundo cliente con keypair distinta es rechazado con error
3227  ANTES de que se le verifique la firma. Sanidad: el conteo de
3228  sesiones del server queda en 0 tras el rechazo.
3229
323025 tests verdes en brahman-handshake (12 unit + 7 handshake legacy
3231+ 3 discovery + 3 libp2p). Ningún regreso en ente-zero (4/4
3232keypair_store).
3233
3234Pendiente futuro:
3235- Denylist explícita (negada — banear peers específicos sin tener
3236  que listar a todos los demás).
3237- Hot reload de la allowlist sin restart del Init (signal SIGHUP o
3238  watch del archivo).
3239- Aplicar la política a nivel de swarm vía
3240  `libp2p_allow_block_list::Behaviour` para rechazar conexiones
3241  ANTES del Noise handshake (hoy se rechaza después, gastando un
3242  round-trip TCP+Noise por cada intento denegado).
3243
3244### feat(brahman-net+handshake): stop_providing automático en cleanup de sesión
3245Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión
3246con outputs cerraba (Farewell, EOF, error), el record que la
3247anunciaba en el DHT seguía vivo hasta su TTL natural (~24h en kad
3248default). Consumers remotos podían descubrir un peer "vivo" que ya
3249no servía nada.
3250
3251Cambios:
3252- **`BrahmanNet::stop_providing(key)`** (nuevo): contraparte simétrica
3253  de `start_providing`. Manda `Command::StopProviding` al swarm que
3254  llama `kad.stop_providing(&key)`. Borra el record del provider
3255  store local al instante; replicas en peers remotos siguen
3256  expirando por TTL (kad no expone retracción cross-peer, simétrico
3257  al hecho de que `start_providing` también propaga eventualmente).
3258- **`brahman_handshake::network::withdraw_outputs(net, card)`**
3259  (nuevo): contraparte de `announce_outputs`. Itera `card.flow.output`
3260  y llama `net.stop_providing(flow_dht_key(...))` por cada uno.
3261- **`server::cleanup`**: extrae la `ResolvedCard` removida del registro
3262  de sesiones (en lugar de descartarla con `remove`) y, si
3263  `config.net` está set, llama `withdraw_outputs(net, &card)` antes
3264  de `broadcast_match_diffs`.
3265
3266Tests: nuevo E2E `dht_discovery_withdraws_on_session_cleanup`:
32671. A registra Card con `flow.output = monad-list:json`.
32682. B descubre a A vía `find_remote_providers` — confirma
3269   `before.contains(&a_peer)`.
32703. Cliente local de A hace `farewell` → cleanup → withdraw_outputs.
32714. Espera a que la sesión salga del registro (señal de cleanup
3272   completado) + 100ms para que el swarm procese el Command.
32735. Nueva query desde B: `after` NO debe contener `a_peer`.
3274
32753 tests verdes en `network_discovery.rs` (positivo, negativo,
3276withdraw). 18 tests totales en handshake + net.
3277
3278Pendiente futuro: retracción cross-peer en kad (requeriría extensión
3279del protocolo libp2p, no soportada hoy). Aceptable: simétrico al
3280modelo de propagación eventual del DHT.
3281
3282### feat(ente-zero): wire de Arje con brahman-net (red P2P opcional + identidad persistente)
3283Cierra el último pendiente del plan de red: Arje ahora puede arrancar
3284opcionalmente con `BrahmanNet` configurado, persistir su identidad
3285libp2p entre reboots, y participar en la malla brahman como nodo
3286público. Sin breaking changes: usuarios actuales (sin env vars) siguen
3287viendo el comportamiento Unix-only de antes.
3288
3289Activación por env vars:
3290- **`BRAHMAN_LISTEN_MULTIADDR`** — si set, activa la red P2P. Ej:
3291  `/ip4/0.0.0.0/tcp/4101` (público), `/ip4/127.0.0.1/tcp/0` (loopback,
3292  port aleatorio). Sin la var, `brahman_net = None` y todo sigue
3293  como antes.
3294- **`BRAHMAN_KEYPAIR_PATH`** — override del path donde se persiste
3295  la keypair Ed25519 de identidad libp2p del nodo. Defaults sensatos:
3296  - PID 1 (root): `/var/lib/brahman/init-keypair.bin`.
3297  - Dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin` →
3298    `$HOME/.local/share/brahman/init-keypair.bin` →
3299    `/tmp/brahman-init-keypair.bin` (último recurso).
3300- **`BRAHMAN_BOOTSTRAP_PEERS`** — lista coma-separada de multiaddrs
3301  para dial-ear al arranque y entrar al DHT. Sin esto, el nodo
3302  arranca aislado hasta que alguien se conecte a él.
3303
3304Comportamiento al activarse:
33051. `keypair_store::load_or_generate(path)` carga la keypair de disco
3306   o genera+persiste una nueva (32 bytes raw, permisos 0o600,
3307   atomic rename). Reboots conservan el `peer_id`.
33082. `BrahmanNet::with_keypair(kp)` arma el swarm con esa identidad.
33093. `net.listen(multiaddr)` espera dirección resuelta y la loggea.
33104. `BRAHMAN_BOOTSTRAP_PEERS` (si set) → dial a cada multiaddr.
33115. El handshake server se levanta con `ServerConfig.net = Some(net)`,
3312   que activa `announce_outputs` automático en el DHT por cada Card
3313   con outputs.
33146. Además del Unix accept loop (existing), se monta un libp2p accept
3315   loop sobre el mismo `Server` compartido. Sesiones locales y
3316   remotas conviven en las mismas tablas (sessions, push_table,
3317   broker, last_matches).
3318
3319Refactor del Unix accept loop: antes consumía el server vía
3320`server.run().await`; ahora usa `Arc<Server>::accept_one().await` en
3321loop para coexistir con el libp2p accept loop sin moverse el server.
3322
3323Degradación grácil en cada paso: si la keypair no carga, si el
3324multiaddr es inválido, si el listen falla, si el bootstrap dial
3325revienta — loggeamos y seguimos en modo Unix-only. La doctrina de
3326PID 1 ("ningún subsistema opcional rompe el bucle primordial") se
3327mantiene.
3328
3329Tests: 4 unit en `keypair_store`:
3330- `generate_persist_and_reload_yields_same_peer_id` — peer_id estable
3331  across reloads (la propiedad fundamental).
3332- `rejects_corrupted_file` — archivo de tamaño incorrecto rechazado.
3333- `persisted_file_is_owner_only` — permisos 0o600 verificados.
3334- `default_path_honors_env` — `BRAHMAN_KEYPAIR_PATH` override
3335  respeta tanto dev como root mode.
3336
3337Ente-zero compila clean. Ningún test del workspace regresa.
3338
3339Lo que esto desbloquea hoy:
3340- Para activar Arje como nodo público, basta:
3341  ```sh
3342  BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 ente-zero
3343  ```
3344- Cualquier consumer (en otra máquina) puede luego dial-ar a ese
3345  multiaddr + descubrir Cards anunciadas via DHT + abrir handshake
3346  remoto firmado.
3347- La identidad del nodo (su `peer_id`) sobrevive reboots, así que
3348  los nodos remotos pueden cachear "este peer_id es Arje en
3349  máquina X" sin invalidarse cada vez.
3350
3351Pendientes futuros:
3352- `stop_providing` al cleanup de sesión (records DHT con TTL ~24h).
3353- Allowlist/Denylist de peers (PKI explícito).
3354- Rotación de keypair sin perder peer_id (multi-key identity).
3355
3356### feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p
3357Cuarto y último paso del plan "el encuentro entre Entes no se
3358restringe a local". Cierra la falla de seguridad que dejaba la red
3359P2P abierta: hasta ahora, un atacante que pudiera dial-ar al
3360multiaddr del Init podía registrar cualquier Card con cualquier
3361label/flow. Fase 3 cierra esto exigiendo que el Hello vía libp2p
3362venga firmado con la **misma keypair Ed25519 que produce el
3363`peer_id` autenticado por Noise**.
3364
3365Modelo:
3366- **Local Unix**: SO_PEERCRED del kernel autentica al cliente. Firma
3367  opcional. Si está presente, igual se verifica (defensa en
3368  profundidad).
3369- **Remoto libp2p**: firma obligatoria. La public key del Hello debe
3370  derivar al `peer_id` que Noise ya autenticó. Si falta o no coincide
3371  → `HandshakeError::Unauthorized`.
3372
3373Wire (`brahman_handshake::messages`):
3374- `Hello.signature: Option<HelloSignature>` (nuevo, default None).
3375- `HelloSignature { public_key: Vec<u8>, signature: Vec<u8> }` —
3376  public_key en formato canónico libp2p (`encode_protobuf`), firma
3377  Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option<WitInterface>)`
3378  serializado postcard.
3379- `SIGNATURE_VERSION = 1` documenta el esquema del payload firmado;
3380  bump al cambiar.
3381
3382Nuevo módulo `brahman_handshake::signature`:
3383- `sign_hello(keypair, card, wit) -> HelloSignature`.
3384- `verify_hello(sig, card, wit, expected_peer) -> Result<(), SignatureError>`.
3385- `SignatureError` tipado (`DecodeKey`, `EncodePayload`, `Invalid`,
3386  `PeerMismatch`, `Missing`, `Unexpected`).
3387
3388Server:
3389- `Session<S>` gana `expected_peer: Option<PeerId>`.
3390- `Server::session_from_libp2p_stream(stream, peer)` (nuevo)
3391  construye Session con `expected_peer = Some(peer)`.
3392  `session_from_stream` (Unix/in-memory) sigue con `None`.
3393- `do_handshake` exige firma + verifica peer match cuando
3394  `expected_peer.is_some()`. Si no, verifica firma presente por
3395  consistencia interna pero no exige que esté.
3396- `network::run_libp2p_accept_loop` ahora usa
3397  `session_from_libp2p_stream(stream.compat(), peer)` para propagar
3398  la identidad libp2p al gate de trust.
3399
3400Client:
3401- `Client::connect_with_stream_signed(stream, card, wit, &Keypair)`
3402  (nuevo) firma el Hello antes de mandarlo.
3403- `Client::connect_with_stream` sigue existiendo sin firma (path
3404  Unix / tests).
3405- `Client::connect`/`connect_with` (Unix) no cambian — siguen sin
3406  firma porque SO_PEERCRED autentica.
3407- `network::connect_libp2p(net, peer, card, wit, keypair)`
3408  **breaking change**: gana parámetro `keypair: &Keypair`.
3409
3410BrahmanNet:
3411- Almacena la `Keypair` en `Arc<Keypair>` (libp2p Keypair no es
3412  Clone; el truco es duplicar el `ed25519::Keypair` interno que sí
3413  es Clone, una copia para Noise/swarm y otra para signing).
3414- `BrahmanNet::keypair() -> Arc<Keypair>` accessor para que callers
3415  puedan firmar con la misma identidad libp2p del nodo sin tener
3416  que mantener la keypair por separado.
3417- `with_keypair` rechaza keypairs no-Ed25519 (RSA/ECDSA/Secp256k1
3418  vendrían a futuro si se necesitan).
3419
3420Tests:
3421- 4 unit en `signature::tests`: roundtrip propio, peer mismatch,
3422  card tampered, signature flipped.
3423- 1 E2E nuevo en `tests/network_libp2p.rs`:
3424  `libp2p_handshake_rejects_mismatched_signing_key` — el cliente
3425  intenta firmar con keypair distinta a la del net; server rechaza.
3426- E2E positivo (`libp2p_handshake_roundtrip`) ahora pasa la keypair
3427  del client_net y debe verificar OK.
3428- Discovery + handshake legacy + signature: 90+ tests verdes en
3429  brahman-handshake/brahman-net/brahman-card/minga-p2p.
3430
3431Lo que esto cierra:
3432- Brahman-net es una malla públicamente dial-able **con
3433  autenticación criptográfica end-to-end**: Noise para el transport,
3434  Ed25519 para las Cards.
3435- La cadena completa de discovery + connect + trust funciona
3436  cross-machine sin paths hardcodeados ni confianza implícita.
3437- El plan original ("el encuentro entre Entes no se restringe a
3438  local, la ejecución remota está pensada desde el principio")
3439  está implementado y testeado.
3440
3441Pendientes (futuro, no de hoy):
3442- `stop_providing` al cleanup de sesión (records DHT viven hasta
3443  TTL ~24h).
3444- Wire de Arje (`ente-zero`) para arrancar opcionalmente con
3445  `BrahmanNet` configurado y `ServerConfig.net = Some(...)`.
3446- Allowlist/Denylist de peers (hoy cualquier peer Ed25519-válido
3447  pasa el trust gate; producción podría querer un PKI explícito).
3448- Persistencia de la keypair de identidad del nodo entre reboots.
3449
3450### feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type
3451Tercer paso del plan "el encuentro entre Entes no se restringe a
3452local". Cuando un Init local acepta una sesión cuya Card declara
3453outputs, anuncia al DHT (Kademlia, vía `brahman-net`) que él provee
3454esos flow types. Cualquier nodo conectado al mismo DHT puede
3455consultar y obtener la lista de `PeerId`s que sirven el flow.
3456
3457API nueva en `brahman_handshake::network`:
3458- `flow_dht_key(flow_name, type_ref) -> [u8; 32]`: blake3 hash de
3459  `"brahman-flow|v1|{flow}|{type_canon}"`. Determinístico cross-host.
3460  Cambiar la canonicalización rompe compatibilidad — el prefijo `v1`
3461  documenta la versión del esquema y obliga a bump al modificar.
3462- `announce_outputs(net, card)`: llama `start_providing` en el DHT
3463  por cada `Flow` en `card.flow.output`. Idempotente, fire-and-forget.
3464- `find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>`:
3465  query DHT por la key derivada. Lista vacía si nadie anuncia o si
3466  la query no resuelve dentro del timeout interno de Kad.
3467
3468Wire en el server:
3469- `ServerConfig` gana `pub net: Option<Arc<BrahmanNet>>`. Si está set,
3470  cada Card registrada con outputs se anuncia automáticamente al DHT
3471  desde `register_session`. `None` = server "ciego al DHT" (correcto
3472  cuando no hay conectividad o el operador no quiere exponer).
3473- `ServerConfig` ahora tiene `Debug` manual (BrahmanNet no implementa
3474  Debug; loggeamos sólo presencia/ausencia).
3475
3476Canonicalización del TypeRef:
3477- `Primitive { name }` → `prim:{name}`
3478- `Wit { package, interface, name }` → `wit:{package}#{interface_or_empty}#{name}`
3479
3480Tests: 2 nuevos en `tests/network_discovery.rs`:
3481- `dht_discovery_finds_remote_provider`: dos nodos, A registra Card
3482  con `flow.output = monad-list:json`, B dial-ea a A y descubre el
3483  `peer_id` de A vía `find_remote_providers`. Asserts contains.
3484- `dht_discovery_negative_unknown_flow`: B busca un flow que nadie
3485  anunció, devuelve lista vacía sin colgarse.
3486
3487Lo que esto desbloquea:
3488- Un `nouser daemon` corriendo en máquina A puede ser descubierto por
3489  un `nouser-explorer` en máquina B sin conocimiento previo del peer
3490  — sólo necesitan compartir DHT (vía bootstrap inicial).
3491- La cadena completa "explorer → daemon → llm-provider" puede cruzar
3492  máquinas, no sólo procesos.
3493
3494Lo que queda para Fase 3 (trust):
3495- Cards remotas se aceptan hoy sin verificación. Para producción se
3496  necesita firma Ed25519 sobre la Card y verificación antes de
3497  aceptar el Hello remoto. Local sigue confiando en SO_PEERCRED.
3498- Stop-providing al cleanup de sesión (hoy records DHT viven hasta
3499  TTL ~24h aunque la sesión cierre).
3500
3501### feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p
3502Segundo paso del plan "el encuentro entre Entes no se restringe a
3503local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
3504MatchEvent / Farewell, frames postcard length-prefixed) ahora también
3505viaja sobre streams libp2p de la malla `brahman-net` — el mismo Init
3506acepta sesiones por Unix socket Y por libp2p indistintamente, y un
3507consumer remoto puede dial-ar al multiaddr y completar handshake.
3508
3509Cambios:
3510- **`Session<S>` y `Client<S>` genéricos**: ambos dejan de estar atados
3511  a `UnixStream` y pasan a ser genéricos sobre `S: AsyncRead +
3512  AsyncWrite + Unpin + Send + 'static`. El path Unix queda como
3513  `Client = Client<UnixStream>` (default genérico). Constructores
3514  nuevos: `Server::session_from_stream(stream)`,
3515  `Client::connect_with_stream(stream, card, wit)`.
3516- **Refactor del post-handshake con split**: `tokio::select!` sobre
3517  `&mut self.stream` requería `S: Sync` indirectamente, y
3518  `libp2p::Stream` no es Sync. Reemplazado por
3519  `tokio::io::split(stream)` → reader loop principal + writer task
3520  separada que drena el push channel. Writer compartido bajo
3521  `Arc<Mutex<WriteHalf<S>>>` para serializar Pong/Error inline con
3522  los MatchEvents pusheados. Cleanup garantizado en todas las ramas.
3523  La lógica del post-handshake migra a funciones libres
3524  (`run_post_handshake`, `handle_inbound_frame`, `cleanup`,
3525  `broadcast_match_diffs`, `do_handshake`, `register_session`,
3526  `validate_hello`).
3527- **Nuevo módulo `brahman-handshake::network`**:
3528  - `BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"`.
3529  - `LibP2pHandshakeStream = Compat<libp2p::Stream>` (alias del
3530    stream una vez convertido al mundo `tokio::io`).
3531  - `run_libp2p_accept_loop(server, net)`: bucle accept sobre el
3532    protocolo que delega cada stream entrante a una `Session`
3533    construida vía `server.session_from_stream(stream.compat())`.
3534    Sesiones libp2p y Unix conviven en el mismo `Server` —
3535    comparten broker, push table, last_matches.
3536  - `connect_libp2p(net, peer, card, wit)`: abre stream libp2p al
3537    `peer` y arranca handshake.
3538  - `NetworkError` tipado (`OpenStream`, `Handshake`, `AcceptStream`).
3539
3540Deps: `brahman-handshake` gana `brahman-net`, `futures`, `tokio-util`.
3541`brahman-net` re-exporta `Multiaddr`, `PeerId`, `Stream`,
3542`StreamProtocol`, `Protocol`, `OpenStreamError` para que callers no
3543necesiten dep directa a libp2p.
3544
3545Tests:
3546- 9 tests unit + integration verdes (sin regresión en el path Unix).
3547- Nuevo `tests/network_libp2p.rs`: test E2E que arma server con
3548  Unix socket + BrahmanNet, hace listen TCP, monta el accept loop;
3549  cliente con su propio BrahmanNet dial-ea al peer_id, completa
3550  handshake remoto, pinguea, farewell. Verifica que la sesión se
3551  registró durante la conversación y se removió tras farewell.
3552
3553Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo
3554flow type, broker query local + remoto).
3555
3556### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
3557Primer paso del plan "el encuentro entre Entes no se restringe a local".
3558El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
3559a una crate compartida `brahman-net` para que cualquier protocolo de la
3560familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
3561una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.
3562
3563Diseño:
3564- `BrahmanNet::{new, with_keypair}` arma el swarm con DHT en modo
3565  Server, Identify auto-poblando el routing table de Kad, y un
3566  `stream::Control` accesible para que cada protocolo registre su
3567  `StreamProtocol` y abra/acepte streams sin acoplarse al swarm.
3568- API de comandos uniforme: `dial`, `listen`, `add_dht_peer`,
3569  `find_closest_peers`, `start_providing`, `find_providers`.
3570- Pública: `peer_id` (libp2p) + `control` (stream::Control).
3571- Re-exporta `Stream` y `StreamProtocol` para que callers no necesiten
3572  importar libp2p directo.
3573
3574Migración:
3575- `minga-p2p::network` reduce de 282 LOC a 22: ahora sólo re-exporta
3576  `BrahmanNet` bajo el alias histórico `LibP2pNode` (zero churn en
3577  `MingaPeer`) y declara la const `SYNC_PROTOCOL = "/minga/sync/1.0.0"`
3578  específica del sub-protocolo Minga.
3579- Cualquier consumer que necesite armar un nodo P2P puede importar
3580  `brahman_net::BrahmanNet` directo sin pasar por minga.
3581- Deps de `minga-p2p` ganan `brahman-net`; el resto del grafo
3582  (libp2p, libp2p-stream, futures, tokio-util) sigue igual porque
3583  `MingaPeer` aún los usa para la lógica específica de sync.
3584
3585Aclaración semántica anclada por el usuario: **Arje** es el init
3586(PID 1, runtime, ente-zero/kernel/soma); **Brahman** es el encuentro
3587entre Entes (handshake/broker/card/sidecar/ahora también net). El
3588nombre de la crate refleja que la malla pertenece al encuentro, no
3589al runtime — Arje puede usar la malla, Minga usa la malla, cualquier
3590futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.
3591
3592Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior
3593verificado idéntico — sólo se movió código, ningún cambio funcional.
3594Próximo: Fase 1 (handshake brahman sobre libp2p stream).
3595
3596### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path
3597Cierra el único debt estructural detectado en el audit de
3598independencia: `nouser-explorer` ya no arrastra `nouser-core`
3599(que aportaba `notify`/`walkdir`/`sled`/`blake3` al grafo de
3600compilación de una UI que sólo habla JSON contra un socket).
3601
3602Cambios:
3603- **Cliente movido**: `engine_socket::client::list_monads` (~60 LOC,
3604  std + serde_json puros) emigra de `nouser_core::engine_socket` a
3605  `nouser_card::query::client`. Vive donde viven los wire types,
3606  consistente con el principio "un consumer importa el contrato,
3607  no el runtime del productor".
3608- **Drop dep**: `nouser-explorer` deja de dependener de
3609  `nouser-core`. Verificado con `cargo tree`: `notify`, `sled`,
3610  `blake3` desaparecen del grafo del binario. (`walkdir` sigue
3611  pero llega vía `gpui_util` → `rust-embed`, fuera de nuestro
3612  control y pre-existente.)
3613- **Fallback "falla hacia la simplicidad"**: nueva función
3614  `resolve_socket()` en el explorer intenta primero broker
3615  discovery; si el broker no responde / no hay init vivo,
3616  fallback directo a `nouser_card::query::transport::default_socket_path()`.
3617  El explorer queda funcional contra un daemon "huérfano"
3618  (corriendo standalone sin init) — completa la cadena
3619  "consciente cuando hay ecosistema, soberano cuando está solo".
3620- `socket_source` en el header gana un tercer estado
3621  `"default-path"` para que el usuario vea por dónde se conectó.
3622
3623Audit estructural confirmó que el resto del ecosistema ya
3624respeta el principio: todos los `yahweh-*` viewers, `minga-cli`,
3625`minga-core`, `nouser-card`, `nouser-nous`, los providers
3626`nouser-nous-{mock,real}` y `nakui-core` corren standalone con
3627soft-fail hacia infra brahman cuando está ausente. Brahman es
3628"pegamento opcional, no chasis obligatorio" — y ahora el grafo
3629de Cargo lo enforcea, no sólo la convención.
3630
3631Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes.
3632El cliente movido se ejercita end-to-end por los 3 tests integración
3633de `engine_socket` (importa ahora `nouser_card::query::client`).
3634
3635### feat(explorer+daemon): discovery dinámico vía broker + query socket
3636La UI deja de hardcodear el socket admin: ahora descubre al daemon
3637nouser vía `MatchEvent::Available` del broker brahman y le consulta
3638sus Mónadas directo, sin pasar por brahman-admin. Cierra el "explorer
3639encuentra al daemon de forma totalmente dinámica" del meta-plan.
3640
3641Pipeline end-to-end:
3642- Daemon publica engine Card con `service_socket = $XDG_RUNTIME_DIR/nouser-engine.sock`
3643  y `flow.output = monad-list:json`.
3644- Daemon binda un Unix socket en ese path y monta un listener
3645  blocking que sirve `nouser_card::query::QueryRequest::ListMonads`,
3646  responde `ListMonadsResponse { engine, monads: Vec<MonadView> }`.
3647- Explorer construye un consumer Card con `flow.input = monad-list:json`
3648  vía `brahman_sidecar::build_consumer_card`, llama
3649  `await_provider_blocking(card, 3s)` y recibe el socket descubierto.
3650- Cachea ese socket; cada poll (2s) llama
3651  `nouser_core::engine_socket::client::list_monads(socket, 2s)`.
3652  Fallo de query → invalida cache → próximo tick re-descubre.
3653
3654Wire types nuevos en `nouser_card::query`:
3655- `QueryRequest::ListMonads` (single variant por ahora).
3656- `ListMonadsResponse { engine: EngineInfo, monads: Vec<MonadView> }`.
3657- `MonadView`: proyección slim de `MonadManifest` SIN `centroid` ni
3658  `members` — la UI no los necesita y eran KB por Mónada que no
3659  tenían por qué viajar cada poll.
3660- `transport::default_socket_path()` con env override
3661  `NOUSER_ENGINE_SOCKET`.
3662- Const `FLOW_MONAD_LIST = "monad-list"`, `FLOW_TYPE_NAME = "json"`.
3663
3664Listener en `nouser_core::engine_socket`:
3665- `spawn_listener(config, db)` arma std::os::unix::net::UnixListener
3666  en thread blocking dedicado. Frecuencia esperada (UI cada 2s) no
3667  amerita tokio.
3668- `client::list_monads(socket, timeout)` — cliente blocking con
3669  `QueryError` tipado (Connect / Io / Serde / Daemon / Timeout / Empty).
3670- 3 tests integración: roundtrip vacío, Mónadas reales, request
3671  inválido devuelve ErrorResponse.
3672
3673Refactor explorer:
3674- Drop dep `brahman-admin`, add deps `brahman-sidecar`, `nouser-card`,
3675  `nouser-core`.
3676- State: `socket: Option<PathBuf>` cache + `snapshot: Option<ListMonadsResponse>`
3677  + `socket_source: "discovery"|"cache"` (sólo informativo).
3678- Tick: `tick(prior_socket)` separado del UI, devuelve un enum
3679  `TickOutcome::{Ok, DiscoveryFailed, QueryFailed}`. Cualquier
3680  fallo invalida la cache → re-discovery automática.
3681- Header reformulado: `Engine 'nouser_engine'  ·  N mónada(s)  ·
3682  socket: /...  (cache|discovery)  ·  watching: /tmp/x`.
3683- Render pintado de un engine card + Mónadas, sin ya iterar
3684  `BrokeredCard` del admin.
3685
3686Trade-offs aceptados:
3687- Polling 2s (no streaming). El broker no empuja cambios de Data
3688  cards hoy; agregar streaming requiere extender el protocolo
3689  handshake. Para snapshot UI, polling 2s es suficiente.
3690- Re-descubrimiento full en cada error de query (en lugar de retry
3691  con backoff). Discovery es barato (~ms vs broker), no vale la
3692  pena la complejidad.
3693
3694Tests: 10 (nouser-card, +3 query) + 27 (nouser-core, +3 engine_socket)
3695+ 4 (sidecar) verdes. Explorer compila clean.
3696
3697### feat(nous-real): cache de embeddings + write-through al CAS de arje
3698Cierra el ciclo de la crítica del usuario: "Si un archivo no ha
3699cambiado su hash en el CAS, Nouser ni siquiera debería pedirle al
3700LLM que re-genere el embedding". El modelo real
3701(`fastembed-allMiniLML6V2-384d`, ~1-50ms por archivo) era invocado
3702ciegamente en cada re-cluster del watcher. Ahora se cachea por
3703`sha256(bytes-vistos) + model_id`.
3704
3705Pipeline en `handle_file`:
37061. Lee primeros 8 KiB (igual que antes).
37072. `file_sha = ente_cas::sha256_of(buf)` — hash de los bytes que el
3708   modelo *realmente* verá (no del archivo completo). Garantiza
3709   que un archivo creciendo más allá de la ventana sin tocar la
3710   cabeza siga sirviendo cache hits.
37113. Cache lookup: HIT → respuesta en ~µs.
37124. MISS → `ente_cas::store(&buf)` (write-through al CAS de arje,
3713   no-fatal si falla) → `backend.embed_one(text)` → `cache.put(...)`.
3714
3715Backend de cache: sled local en
3716`$XDG_CACHE_HOME/brahman/nouser-nous-real-embed-cache.sled`. Tree
3717versionado `embed_cache_v1`; el `MODEL_ID` viaja en la key, así que
3718cambiar de modelo invalida el cache implícitamente. Override por env
3719`NOUSER_NOUS_REAL_CACHE`.
3720
3721Encoding compacto: cada `Vec<f32>` se serializa como bytes
3722little-endian (4B por f32, sin overhead). Para el modelo default
3723(384-d) son 1.5 KiB por entry. Decode tolera bytes corruptos
3724(longitud no-múltiplo de 4 → `None`, no panic).
3725
3726Por qué sled y no `ente-cas` directo: el CAS de arje es flat
3727sha256-keyed; la cache necesita un mapeo `(file_sha, model_id) →
3728embedding`, no expresable como entry CAS. El write-through a CAS
3729queda como registro consultable + futura GC.
3730
3731API:
3732- `EmbedCache::open()` → abre sled, idempotente.
3733- `EmbedCache::open_at(dir)` para tests.
3734- `EmbedCache::get(sha, model)` → `Option<Vec<f32>>`.
3735- `EmbedCache::put(sha, model, &[f32])` → no-fatal en error.
3736- `EmbedCache::len()` → contador para logs (best-effort).
3737
3738Mock NO se modifica — su embedding pseudo-32d es metadata-hashing
3739puro, sin costo. Cachearlo sería overhead.
3740
3741Tests: 5 unitarios (`roundtrip_returns_same_vector`, `miss_returns_none`,
3742`different_models_do_not_collide`, `different_content_different_keys`,
3743`corrupted_value_returns_none`). Verdes con `--features embeddings`;
3744stub mode (sin feature) sigue compilando sin tocar cache.
3745
3746### chore(nakui): alinear `nakui-core` con `[workspace.package]` y deps compartidas
3747Cleanup de drift de convenciones: `nakui-core` era el único crate del
3748monorepo que mantenía `version = "0.1.0"` / `edition = "2021"` /
3749`thiserror = "1"` hardcoded, mientras el resto heredaba del workspace
3750y usaba `thiserror = "2"`. Eso significaba que un bump global de versión
3751o de edition se olvidaba sistemáticamente de nakui.
3752
3753Cambios:
3754- `[package]`: `version`, `edition`, `rust-version`, `license`, `authors`,
3755  `publish` → todos `*.workspace = true`. Agregado `description` (cumple
3756  convención del resto de crates).
3757- Deps compartidas migradas a `{ workspace = true }`: serde, serde_json,
3758  thiserror (v1→v2), tokio, ulid, sha2.
3759- `uuid` migrado a `{ workspace = true, features = ["serde"] }` — la
3760  feature `serde` no está en el workspace dep porque nakui es el único
3761  user; queda local opt-in en lugar de inflar el dep común.
3762- Deps específicas de nakui (sin compartición posible): rhai, petgraph,
3763  surrealdb permanecen inline con versión local.
3764
3765Verificación: `cargo build -p nakui-core` verde tras el bump de
3766`thiserror` v1→v2 — el `#[derive(Error)]` de los 14+ enums de error
3767en nakui no requirió ajustes (la API de derive es backwards-compatible
3768para los patrones simples). `cargo test -p nakui-core --lib`: 27/27
3769verdes, sin regresión.
3770
3771### feat(card): `Card::new(label)` — alternativa segura a `Default::default()`
3772Cierra la trap documentada de `Card::default()` que devuelve `id =
3773Ulid::nil()`. Usar `Card::default()` "viva" colisionaba con cualquier
3774otra Card default-construida bajo el mismo id `00000000…`. La fix no
3775es romper `Default` (sigue siendo determinista, requerido por callers
3776que lo usan como template para deserialización), sino agregar un
3777constructor explícito:
3778
3779  let card = Card {
3780      kind: CardKind::Data,
3781      payload: Payload::Embedded(json),
3782      ..Card::new("mi-modulo.algo")
3783  };
3784
3785`Card::new(label)` asigna `id = Ulid::new()` (único) + `label`
3786provisto, dejando el resto en defaults seguros (Virtual / OneShot /
3787Ente). Pensado para usarse en struct-literals con override parcial,
3788igual sintaxis que el patrón viejo pero sin la trap.
3789
3790Refactor de call sites:
3791- `brahman_sidecar::discovery::build_consumer_card` → `..Card::new(label)`
3792- `nouser daemon::build_engine_card` → `..Card::new("brahman.nouser_engine")`
3793
3794`Default` se mantiene tal cual con docstring expandida que advierte
3795explícitamente sobre el uso "vivo" y apunta a `Card::new`. Tests
3796existentes y el patrón `nouser_card::MonadManifest::to_brahman_card`
3797(que asigna el id estable de la Mónada, no uno fresco) NO se
3798modifican — `Default` sigue siendo correcto cuando el caller
3799sobreescribe `id` explícitamente.
3800
3801Tests: 3 unitarios nuevos en brahman-card (`new_assigns_real_ulid_and_label`,
3802`new_yields_distinct_ids_per_call`, `default_keeps_nil_id_for_struct_update_pattern`).
380315 tests verdes (era 12).
3804
3805### feat(sidecar): API reusable de discovery vía broker
3806Promueve el patrón ad-hoc `discover_producer_socket` (que vivía
3807inline en `nouser attract --remote`) a un módulo público
3808`brahman_sidecar::discovery`. Cualquier consumer puede ahora
3809preguntar al broker "¿quién provee este TypeRef?" con dos llamadas:
3810
3811  // Construir un consumer Card mínimo (Ente, Oneshot, Virtual)
3812  let card = brahman_sidecar::build_consumer_card(
3813      "mi-cli",
3814      "embed-result",   // flow.input.name
3815      "json",            // TypeRef::Primitive { name }
3816  );
3817
3818  // Bloqueante (CLIs, std-thread loops):
3819  let socket: PathBuf = brahman_sidecar::await_provider_blocking(
3820      card, Duration::from_secs(3),
3821  )?;
3822  // O async (módulos con runtime tokio propio):
3823  let socket = brahman_sidecar::await_provider(card, timeout).await?;
3824
3825API:
3826- `build_consumer_card(label, flow_name, type_name) -> Card`
3827  abstrae la verbosidad del struct-literal repetido en cada caller.
3828  Genera un `id: Ulid::new()` real (no nil → seguro contra
3829  colisiones en el broker).
3830- `await_provider(card, timeout) -> Result<PathBuf, ConsumerError>`
3831  conecta al init, espera `MatchEvent::Available`, devuelve
3832  `producer_service_socket`, manda Farewell. Ignora eventos
3833  `Lost` durante el await (no aplican al arranque).
3834- `await_provider_blocking(card, timeout)` arma su propio
3835  runtime `current_thread` para mundos no-async.
3836- `ConsumerError` con variantes tipadas: `Connect { socket, source }`,
3837  `NoProvider { flow, type_ref, timeout }`, `Client(ClientError)`,
3838  `Runtime(String)`. Adiós al `Box<dyn Error>` de antes.
3839
3840Refactor en `nouser daemon`:
3841- `discover_producer_socket` (60 LOC inline en `bin/nouser.rs`) → 5
3842  líneas que delegan en el helper.
3843- `remote_embed` ya no construye su propio runtime tokio.
3844
3845Próximo consumer natural: `nouser-explorer`. Hoy renderea
3846`StatusSnapshot` vía socket admin (introspección pura). El día que
3847quiera **interactuar** con un Ente — p. ej., disparar un re-embed
3848desde la UI — usa este helper para resolver el socket del provider
3849sin hardcodear paths.
3850
3851Nota sobre identidad: este commit fuerza `Ulid::new()` para los
3852consumer Cards generados, evitando la trampa documentada del
3853`Card::default()` que devuelve `Ulid::nil()`. La fijación global de
3854`Default` queda como cleanup separado (requiere auditar que ningún
3855caller dependa del determinismo de `nil`).
3856
3857Tests: 4 unitarios nuevos en `discovery::tests` (id no-nil, id
3858único por llamada, formateo de TypeRef::Wit, fallback sin input).
3859Workspace verde.
3860
3861### feat(nouser+sidecar): watcher con debounce + re-publish al broker
3862Cierra las dos limitaciones del watcher previo: ya no spamea N veces por
3863una sola edición, y el broker ve los cambios estructurales en lugar de
3864quedarse con manifests congelados al arranque.
3865
3866  $ nouser daemon /tmp/x &
3867  $ touch /tmp/x/src/a.rs /tmp/x/src/b.rs /tmp/x/src/c.rs
3868  # daemon log (un solo batch, no 9 reacciones):
3869  [watcher] ⚙ batch: 6 path(s) coalescidos → re-scan
3870  [watcher] ✦ x/src nace (3 miembros, lens=Code)
3871  [watcher] ⌃ delta: 1 nuevas, 0 refrescadas, 0 cerradas — 3 sesiones vivas
3872
3873Mecánica del debounce (150ms):
3874- `spawn_fs_watcher` arma dos threads: **dispatcher** filtra eventos
3875  notify Create/Modify/Remove a un canal de paths; **coordinator**
3876  mantiene `HashMap<PathBuf, Instant>` y dispara batch sólo cuando
3877  todos los paths llevan ≥150ms quietos.
3878- Un `:w` típico de vim (~5 eventos por archivo) colapsa a 1 batch.
3879
3880Mecánica del re-publish:
3881- `SidecarPool` ahora trackea `HashMap<Ulid, AbortHandle>` indexado
3882  por `Card.id`. Llamar `pool.spawn(card)` con un id ya presente
3883  aborta la sesión previa y abre una nueva — `spawn` se vuelve
3884  idempotente: re-publicar una Mónada cuya composición cambió
3885  refresca su sesión en el broker sin dejar zombies.
3886- Nueva API `pool.drop_session(id)` para cerrar una sesión
3887  explícitamente cuando una Mónada desaparece (directorio quedó
3888  bajo `min_files` o se borró).
3889- `pool.live_sessions()` para introspección/logs.
3890- `process_change_batch` re-scanea + re-clusteriza con hidratación,
3891  diffea contra prior_monads, y para cada Mónada decide:
3892  - removida → `drop_session`
3893  - nueva → `spawn` con ✦
3894  - composición cambió (members o centroid distintos) → `spawn` con ↻
3895  - idéntica → no-op
3896
3897Trade-off aceptado: re-scan global por batch (no incremental). Es
3898O(N archivos) por evento y para árboles típicos (<10k) cae en
3899<100ms. Optimizar a re-cluster parcial cuando duela.
3900
3901Tests: workspace completo verde.
3902
3903### feat(nouser): notify watcher — el sistema reacciona en tiempo real
3904El daemon ahora monta un `notify::recommended_watcher` recursivo
3905sobre el directorio. Cada `Create`/`Modify` de archivo regular
3906dispara: embedding del archivo, filtro por `centroid_model`, ranking
3907contra centroides existentes, log con marker 🧲 / · según supere
3908el umbral de atracción.
3909
3910  $ nouser daemon /tmp/x &
3911  # en otra terminal:
3912  $ vim /tmp/x/src/nuevo.rs
3913  # daemon log:
3914  [watcher] 🧲 /tmp/x/src/nuevo.rs  →  x/src  (0.7470)
3915
3916  $ echo "edit" >> /tmp/x/docs/n1.md
3917  [watcher] 🧲 /tmp/x/docs/n1.md  →  x/docs  (0.8169)
3918
3919Mecánica:
3920- DB pasa a `Arc<Mutex<MonadDb>>` para sharing con el thread del
3921  watcher.
3922- Watcher en thread dedicado (`nouser-watcher`); reacciona sólo a
3923  Create/Modify, ignora Access/Metadata-only.
3924- `react_to_change(path, metadata, db)` computa embedding,
3925  filtra por `centroid_model`, busca best attraction.
3926- No re-publica al broker ni muta DB — sólo observa y narra. La
3927  invalidación selectiva (re-cluster + replace_monads + diff
3928  publish) queda como work futuro.
3929
3930Limitación conocida: `notify` emite múltiples eventos por una sola
3931edición (Create + Modify, etc.). Sin debounce, el watcher reporta
3932varias veces. Aceptable para demo; production conviene debounce
3933~100ms por path.
3934
3935Tests: 7 (card) + 24 (core) verdes, 0 errores, 0 warnings.
3936
3937### feat(nouser): hidratación del daemon vía sled + path_hint
3938El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene
3939Mónadas previas con `centroid_model` válido, las publica instantáneo
3940y el re-scan reusa sus IDs vía `path_hint`.
3941
3942Schema:
3943- `MonadManifest.path_hint: Option<String>` — identidad estable
3944  derivada del origen (para `by_directory`, el parent dir
3945  canónico). Permite reusar ULID across re-scans.
3946
3947Algoritmo (cluster):
3948- Nueva fn `cluster::by_directory_hydrated(files, min_files,
3949  prior: Option<&MonadDb>)`. Cuando hay `prior`, busca Mónada con
3950  mismo `path_hint` Y mismo `centroid_model`; si la encuentra,
3951  reusa `id`, `lineage` y `created_at_ms`.
3952- `by_directory` queda como wrapper sin hidratación (back-compat).
3953
3954Daemon (cmd_daemon):
39551. Open sled si NOUSER_DB_PATH existe.
39562. Publica las Mónadas previas con `centroid_model` válido (las
3957  inválidas se descartan con log explícito).
39583. Re-scan + `by_directory_hydrated(prior=&db)`.
39594. Sólo spawnea sidecars para Mónadas con id que NO estaba en la
3960  hidratación inicial. Los path_hints existentes preservan identidad,
3961  evitando duplicados en el broker.
39625. Persiste el set actualizado.
3963
3964Validación end-to-end:
3965
3966  $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core
3967  # arranque 1: DB vacía
3968    re-scan 102 archivos → 5 mónadas
3969    1 ente + 5 mónadas vivas (5 nuevas vs hidratación)
3970
3971  $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core
3972  # arranque 2: DB poblada
3973    hidratadas 5 mónadas previas en O(1)
3974    re-scan 102 archivos → 5 mónadas
3975    1 ente + 5 mónadas vivas (0 nuevas vs hidratación)
3976
3977Costo del arranque 2: ~0.06s user CPU. Antes (sin hidratación) era
3978re-scan + cluster + spawn x N — segundos enteros para árboles grandes.
3979
3980Tests: 7 (card) + 24 (core) verdes.
3981
3982### feat(nouser): centroid_model — versionado de embeddings
3983Protege contra el bug silencioso de mezclar centroides de modelos
3984distintos (mock 32-d vs real 384-d), que daba scores sin sentido.
3985
3986- `MonadManifest.centroid_model: Option<String>` taggea qué modelo
3987  produjo el `centroid`. `None` = legacy pre-versioning.
3988- `nouser_core::embed::MODEL_ID = "nouser-pseudo-32d"`. El cluster lo
3989  setea en cada Mónada que genera.
3990- `nouser-nous-mock` reusa la misma constante (`use
3991  nouser_core::embed::MODEL_ID`); produce vectores idénticos al
3992  cluster local, así que reportar el mismo ID es honesto.
3993- `nouser-nous-real` reporta `"real-fastembed-allMiniLML6V2-384d"`
3994  (dim distinta, semántica distinta).
3995- `cmd_attract` ahora:
3996  - Captura el `model_id` del embedding del target (local o remote).
3997  - Filtra Mónadas cuyo `centroid_model` no matchee.
3998  - Reporta `embed: <source> (<model>)` y `skipped: N mónadas con
3999    centroid_model distinto` cuando descarta.
4000
4001Resultado operativo: cambiar de mock a real (vía
4002`BRAHMAN_BROKER_CONTEXT=prod`) hace que `attract` filtre las Mónadas
4003viejas con cero score en lugar de fingir que las puede comparar.
4004
4005## 2026-05-08
4006
4007### chore: profile.dev slim — target/ ~50% más liviano
4008Cambios en `[profile.dev]` raíz para que builds futuras no desborden
4009disco. Decisiones:
4010- `debug = "line-tables-only"`: stack traces correctos, drop del resto
4011  de symbols. Sin pérdida real para nuestro flujo.
4012- `split-debuginfo = "unpacked"`: relink más rápido, debuginfo en
4013  archivos aparte.
4014- `codegen-units = 256`: paralelismo + builds incrementales chicas.
4015- Override `[profile.dev.package.X]` para los pesados (gpui, ort,
4016  fastembed, tokenizers, image): `opt-level = 1`, `debug = false`.
4017  No los debuggeamos línea por línea, no necesitan info pesada.
4018
4019Resultado: binarios ~3× más livianos. ente-zero 125→47 MB; mock-nous
4020~50→22 MB.
4021
4022### feat(nouser): dynamic binding — consumer descubre el provider vía broker
4023Cierra el bucle prometido por `priority_contexts`: el cliente ya no
4024hardcodea el socket del provider de embeddings. En su lugar:
4025
40261. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (atajo explícito).
40272. Si no, abre `brahman_handshake::client::Client` al `brahman-init`,
4028   anuncia un consumer Card mínimo con `flow.input = embed-result:json`,
4029   espera 3s por el primer `MatchEvent::Available`, y usa el
4030   `producer_service_socket` que viaja en el evento.
4031
4032Esto activa el swap automático mock↔real:
4033- `BRAHMAN_BROKER_CONTEXT=test`: el bias `+1 en test` del mock lo hace
4034  ganar; consumer recibe el socket del mock.
4035- `BRAHMAN_BROKER_CONTEXT=prod`: el bias del real lo hace ganar.
4036- Sin contexto: empate alfabético entre los presentes.
4037
4038Validación end-to-end:
4039
4040  $ ente-zero & nouser-nous-mock &
4041  $ # Sin NOUSER_NOUS_SOCKET:
4042  $ nouser attract --remote crates/core archivo.rs
4043    embed: remote
4044    🧲  0.9058  ente-brain/src  ...
4045  (mock log confirma "embed_file path=...")
4046
4047Cambios:
4048- `nouser-core` Cargo.toml: deps directas brahman-handshake + tokio.
4049- `cmd_attract` resuelve el socket por discovery antes de llamar a
4050  `embed_via(&path, file)` (mini-runtime tokio current_thread inline).
4051
4052Bug que se descubrió en el camino: la "flakiness" reportada de
4053`cargo test --workspace` era disco lleno (24 GB en `target/`), no
4054condición de carrera. Con `cargo clean` + profile slim, todos los
4055tests pasan deterministas.
4056
4057### feat(nouser): yahweh widget — `nouser-explorer` panel GPUI
4058Bin GPUI standalone que consulta `brahman-admin` cada 2s y renderea
4059todas las sesiones del Init como cards. Cierra el círculo visual del
4060ecosistema brahman.
4061
4062- Crate nuevo `crates/apps/nouser-explorer` (deps: brahman-admin,
4063  brahman-card, gpui).
4064- Ventana 900×640 con header del estado del Init, banner de error
4065  cuando no conecta, y lista de cards (una por sesión).
4066- Cada card muestra: kind + label + lifecycle, ULID corto, summary
4067  (si data), keywords, lens hint, service_socket si está, y refs
4068  (RelationshipKind → target_label). El borde izquierdo coloreado
4069  diferencia ente (azul) de data (lavanda).
4070- `cx.spawn(async move |this, cx| { … })` corre el loop de refresh
4071  en el GPUI executor; `query_blocking` se usa porque GPUI no provee
4072  un runtime tokio.
4073- Nuevo helper en brahman-admin: `client::query_blocking(path)` —
4074  versión sync de `query()`, para callers con su propio executor.
4075
4076Uso:
4077
4078  $ ente-zero & nouser daemon crates/core &
4079  $ cargo run -p nouser-explorer
4080  # ventana muestra ~6 cards en vivo, refrescando cada 2s.
4081
4082cargo check --workspace: 0 errores, 0 warnings.
4083
4084### feat(nouser): persistencia sled write-through del MonadDb
4085`MonadDb` ahora soporta backend dual:
4086
4087- `MonadDb::new()` → memoria pura (default, back-compat).
4088- `MonadDb::open(path)` → sled-backed con cache en memoria. Carga
4089  contenido existente al abrir; cada `insert_*` hace write-through
4090  (cache + sled).
4091
4092Diseño:
4093- 2 trees sled: `files` y `monads`.
4094- Wire format: serde_json (ergonomía + inspectability con sled-cli;
4095  los manifests son chicos, JSON gana sobre postcard aquí).
4096- Reads SIEMPRE desde la cache — sled se consulta sólo al abrir.
4097- `replace_monads()` purga el tree de sled antes de escribir.
4098
4099Bin nouser: nueva env var `NOUSER_DB_PATH`. Si está set, persiste
4100en esa ruta; si no, in-memory:
4101
4102  $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core
4103  scan: 102 archivos en crates/core, 5 mónadas
4104  $ ls /tmp/monads.sled
4105  blobs  conf
4106  $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core
4107  # segunda corrida re-escribe la DB con el nuevo scan
4108
4109Tests nuevos en db.rs:
4110- `persistence_roundtrip` — escribe, cierra, reabre, datos están.
4111- `replace_monads_purges_persistent_tree` — replace limpia el tree.
4112
411324 tests en nouser-core (era 22, +2).
4114
4115### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
4116Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
4117Para módulos que publican muchas sesiones (nouser daemon con 50+
4118Mónadas) eso es 50 threads + 50 runtimes. Ahora: **un thread + un
4119runtime tokio current_thread** que hostea N tasks de sidecar.
4120
4121API nueva (aditiva, no rompe `spawn`/`spawn_with_handle`):
4122
4123  let pool = SidecarPool::new()?;
4124  pool.spawn(card1);
4125  pool.spawn(card2);
4126  pool.spawn_conscious(card_wit, wit);
4127  pool.spawn_with_config(SidecarConfig::new(c).with_wit(w));
4128  // pool drop = todas las sesiones cierran.
4129
4130`run_client` se hace pública para que el pool pueda enqueuar tasks
4131externos al runtime con `handle.spawn(run_client(config))`.
4132
4133`nouser daemon` migrado al pool. Verificación con `ps -L`:
4134
4135  $ ps -L -p $(pidof nouser)
4136    LWP   CMD
4137    28817 nouser              # main thread
4138    28819 brahman-sidecar     # pool thread (todas las sesiones)
4139
4140Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar
4141cuántas Mónadas se publiquen.
4142
4143### feat: Crossreferencia — Card.references como grafo del fractal
4144Las Cards ahora declaran sus relaciones con otras Cards. El Engine
4145posee Mónadas; las Mónadas declaran que son poseídas por el Engine.
4146La UI puede cruzar el grafo sin discovery especial.
4147
4148- `brahman-card`:
4149  - `RelationshipKind { Owns, OwnedBy, Processes, ProcessedBy, Sibling }`.
4150  - `CardReference { kind, target_id, target_label }` — `target_label`
4151    es cache del label en el momento de declarar (la UI puede pintar
4152    sin resolver).
4153  - `Card.references: Vec<CardReference>` y espejo en `WireCard`.
4154    Conversiones `From` propagan.
4155- `brahman-broker::BrokeredCard` propaga `references`.
4156- `brahman-status` imprime cada referencia: `ref OwnedBy → label (id)`.
4157- **nouser daemon**: cada Mónada que publica añade
4158  `RelationshipKind::OwnedBy` apuntando al engine. La declaración es
4159  unilateral; el engine no necesita conocer las IDs de antemano.
4160
4161Validación end-to-end:
4162
4163  $ ente-zero & nouser daemon crates/core
4164  $ brahman-status
4165  Sessions (6):
4166    [ente]  ... brahman.nouser_engine
4167    [data]  ... brahman-handshake/src
4168        ref OwnedBy  →  brahman.nouser_engine  (01K...)
4169        summary: 6 archivos...
4170    [data]  ... ente-brain/src
4171        ref OwnedBy  →  brahman.nouser_engine  (01K...)
4172    ...
4173
4174### feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten
4175Cierra el ciclo del swap automático de Nous (mock↔real):
4176
4177- **Schema** (`brahman-card`): `Card.service_socket: Option<PathBuf>` y
4178  espejo en `WireCard`. Conversiones `From` propagan. Es el path del
4179  **data plane** (distinto del socket del Init); cualquier consumer
4180  que matchee con esta Card puede conectar directo sin discovery
4181  adicional.
4182- **Broker** (`brahman-broker`): `BrokeredCard` propaga
4183  `service_socket` desde la Card. Sin participación en el matching —
4184  sólo metadata para los observadores.
4185- **MatchEvent** (`brahman-handshake`): nuevo campo
4186  `producer_service_socket: Option<PathBuf>`. Cuando el server emite
4187  `Available`, busca la `BrokeredCard` del productor en el broker y
4188  copia su `service_socket`. El consumer recibe la ruta completa para
4189  conectar.
4190- **Transport** (`nouser-nous`): `provider_socket_path(provider: &str)`
4191  devuelve `nouser-nous-{provider}.sock` por default — mock y real
4192  coexisten en sockets distintos (Phase D-4). `default_socket_path()`
4193  conserva el comportamiento single-provider.
4194- **Providers**: mock declara `service_socket =
4195  /run/user/X/nouser-nous-mock.sock`; real declara
4196  `nouser-nous-real.sock`. La Card se construye DESPUÉS del bind para
4197  que el path declarado sea el real.
4198- **Status**: `brahman-status` imprime `socket:` por sesión cuando
4199  está presente.
4200
4201Validación end-to-end:
4202
4203  $ ente-zero & nouser-nous-mock & nouser-nous-real &
4204  $ ls /run/user/1001/nouser-nous-*.sock
4205    nouser-nous-mock.sock
4206    nouser-nous-real.sock
4207
4208  $ brahman-status
4209  Sessions (2):
4210    [ente]  ... nouser.nous_real
4211        socket:     /run/user/1001/nouser-nous-real.sock
4212        in  embed-request: Primitive { name: "json" }
4213        out embed-result: Primitive { name: "json" }
4214    [ente]  ... nouser.nous_mock
4215        socket:     /run/user/1001/nouser-nous-mock.sock
4216        in  embed-request, out embed-result
4217
4218Pendientes para futuro (no críticos):
4219- nouser-core attract --remote todavía usa NOUSER_NOUS_SOCKET hardcoded
4220  o `default_socket_path()`. El siguiente paso es subscribirse al
4221  MatchEvent del broker y usar `producer_service_socket` directo —
4222  con eso `BRAHMAN_BROKER_CONTEXT=test/prod` swapea provider sin
4223  tocar al consumer.
4224
4225### refactor(nouser): labels de Mónada con 2 componentes del path
4226Resuelve la fricción visual de monorepos donde múltiples Mónadas se
4227llamaban "src". Nueva función `label_from_path` toma los últimos hasta
42282 componentes normales del path y los une con `/`.
4229
4230  $ nouser scan crates/core
4231    [01K..] brahman-admin/src       card=5
4232    [01K..] brahman-handshake/src   card=6
4233    [01K..] ente-brain/src          card=11
4234    [01K..] ente-kernel/src         card=4
4235    ...
4236
4237Tests añadidos: `label_from_root_only_one_component`,
4238`label_from_deep_path_takes_last_two`. Tests existentes actualizados
4239con los nuevos labels.
4240
4241### feat(nouser): Phase D-2 — proveedor Nous real (LLM) detrás de feature flag
4242Cierra el ciclo del módulo Nous: existe un proveedor que produce
4243embeddings reales con un modelo LLM, mientras que `cargo build` sin
4244features sigue siendo liviano (no descarga ni compila ML deps).
4245
4246Crate nuevo:
4247
4248- `crates/modules/nouser/nous-real`: bin con dos modos según feature.
4249  - **Sin feature (default)**: stub. Bin compila en ~10s, arranca,
4250    sidecarea a brahman-init declarando la Card de real-nous, escucha
4251    en el socket Nous, y rechaza toda request con `ErrorResponse {
4252    error: "compilado sin la feature embeddings. Rebuild con
4253    cargo build -p nouser-nous-real --features embeddings" }`.
4254    `cargo build --workspace` sigue siendo limpio.
4255  - **Con `--features embeddings`**: pulls `fastembed = "4"`. Ese crate
4256    arrastra `ort 2.0.0-rc.9` (ONNX Runtime con binarios descargados
4257    por Cargo) + `tokenizers 0.21` + ~30 deps más. Compila en ~50s.
4258    Modelo default: `all-MiniLM-L6-v2` (384-d, descargado a
4259    `~/.cache/fastembed` la primera vez).
4260  - `EmbedText`: pasa el texto al modelo, devuelve vector 384-d.
4261  - `EmbedFile`: lee primeros 8KiB con UTF-8 lossy, embed como texto.
4262    Para binarios el resultado no es semánticamente útil — caller
4263    decide.
4264  - `Ping`: devuelve `model_id` y `embed_dim` reales.
4265
4266- Card de real-nous:
4267  - label `nouser.nous_real` (distinto del mock para coexistir).
4268  - `priority_contexts.prod = { priority_offset: +1 }`. En contexto
4269    prod gana sobre el mock; en `test` el mock gana por su propio
4270    `+1`. Sin contexto activo, empate alfabético entre ambos.
4271
4272Validación end-to-end con modelo real:
4273
4274  $ cargo build -p nouser-nous-real --features embeddings   # ~50s
4275  $ ente-zero & nouser-nous-real &
4276  $ # probe vía python al socket Unix:
4277  $ echo '{"kind":"embed_text","payload":{"text":"hello brahman"}}' \
4278    | python3 -c "..." | head
4279    model: real-fastembed-allMiniLML6V2-384d
4280    elapsed_ms: 8
4281    embed_dim: 384
4282    first 5 values: [0.0034, -0.0036, 0.0078, -0.0218, -0.0162]
4283
4284Tradeoff conocido: las dimensiones del mock (32-d) y real (384-d) son
4285incompatibles. Cambiar de proveedor invalida los centroides cacheados
4286de Mónadas. Documentar como "limpiar DB al cambiar proveedor".
4287
4288Workspace state:
4289- cargo build --workspace sigue limpio sin features (no ML).
4290- cargo build -p nouser-nous-real --features embeddings funciona.
4291- 0 errores, 0 warnings en ambos modos.
4292
4293Pendientes para D-3 / futuro:
4294- Discovery de socket: hoy el consumer hardcodea NOUSER_NOUS_SOCKET.
4295  Para que el broker brahman elija real vs mock per-contexto, falta
4296  inyectar el socket del provider electo en el MatchEvent o exponer
4297  un broker query "dame el socket de la sesión X".
4298- Coexistencia: hoy los dos providers compiten por el mismo socket
4299  path por default. Habría que parametrizarlos a sockets distintos
4300  cuando coexistan.
4301
4302### feat(nouser): Phase D — proveedor Nous mock + cliente remoto
4303Cierra el patrón "Nous como módulo aparte intercambiable": el contrato
4304del proveedor de embeddings vive en su crate, el mock determinístico
4305implementa ese contrato sirviéndolo por Unix socket, y `nouser-core`
4306sabe consumirlo remotamente. El switch entre mock y real (futuro) se
4307hará vía priority_contexts en el broker.
4308
4309Crates nuevos:
4310
4311- `crates/modules/nouser/nous`: contrato compartido. Tipos
4312  `EmbedRequest`, `RequestKind { EmbedFile, EmbedText, Ping }`,
4313  `EmbedFilePayload`, `EmbedTextPayload`, `EmbedResponse`,
4314  `PingResponse`, `ErrorResponse`. Wire format: line-delimited JSON
4315  por Unix socket, single-shot per conexión. Constants para los nombres
4316  de flow (`embed-request`/`embed-result`) y el tipo (`json`). Helper
4317  `transport::default_socket_path()` con env var
4318  `NOUSER_NOUS_SOCKET`.
4319- `crates/modules/nouser/nous-mock`: bin `nouser-nous-mock`. Sidecarea
4320  a brahman-init con Card kind=Ente declarando los flows
4321  `embed-request:json`/`embed-result:json` y un
4322  `priority_contexts.test = { priority_offset: +1 }` (gana sobre
4323  cualquier real-nous en contexto test). Bind del socket Nous, accept
4324  loop, despacha por `RequestKind`. EmbedFile usa
4325  `nouser_core::embed::embed` (los pseudo-embeddings de Phase C).
4326  Modelo: `mock-pseudo-32d`.
4327
4328Cambios:
4329
4330- `nouser-core`: dep nueva `nouser-nous`. Subcomando `attract` ahora
4331  acepta `--remote` que abre un socket UnixStream blocking, envía un
4332  `EmbedRequest` y lee la response. Imprime `embed: local|remote`
4333  para que se vea cuál ruta corrió.
4334
4335Validación end-to-end (un solo terminal, varios procesos):
4336
4337  $ ente-zero &
4338  $ nouser-nous-mock &
4339  $ NOUSER_MIN_FILES=5 nouser daemon crates/core &
4340  $ brahman-status
4341
4342  Sessions (7):
4343    [ente] nouser.nous_mock      flows: embed-request, embed-result
4344    [ente] brahman.nouser_engine
4345    [data] src   summary: 6 archivos en crates/core/brahman-handshake/src
4346    [data] graph summary: 7 archivos en crates/core/ente-zero/src/graph
4347    ...
4348
4349  $ nouser attract --remote crates/core <archivo.rs>
4350    embed: remote
4351    🧲  0.9058  src  ...
4352
4353  Mock log: "embed_file path=crates/modules/nouser/core/src/embed.rs"
4354
4355Bug encontrado y corregido en el camino:
4356- `ContextBias` tenía `#[serde(skip_serializing_if = ...)]` en sus
4357  campos. Postcard NO soporta skip-condicional (formato no
4358  self-describing): el serializer omitía bytes que el deserializer
4359  esperaba, rompiendo la wire de cualquier Card con
4360  `priority_contexts` poblada.
4361- Fix: removidos los `skip_serializing_if` de `ContextBias`. JSON
4362  pretty ahora emite `{"pin_to": null, "priority_offset": 0}` en lugar
4363  de objeto vacío. Trade-off aceptado por compatibilidad de wire.
4364- Test nuevo en brahman-card: `wirecard_postcard_with_priority_contexts`
4365  que ejercita el roundtrip completo postcard.
4366
4367Tests acumulados: 75 (card 12 +1 nuevo, broker 15, handshake 9,
4368card-wit 4, admin 0, nouser-card 7, nouser-core 20, nouser-nous 2).
4369cargo check --workspace: 0 errores, 0 warnings.
4370
4371Próximo natural: Phase D-2 — `real-nous` con un modelo ONNX/Llama de
4372text-embedding. La infraestructura ya está lista: declara la misma
4373Card con `priority_contexts.prod = { priority_offset: +1 }` y el
4374swap es transparente para el consumer.
4375
4376### feat(nouser): Phase C — pseudo-embeddings + atracción por centroide
4377El "imán semántico" matemático del diseño Kairos, sin LLM. Cada
4378archivo se proyecta a un vector 32-d derivado de sus metadatos; cada
4379Mónada calcula su centroide; archivos nuevos se asignan por cosine
4380similarity contra los centroides existentes.
4381
4382Cambios:
4383
4384- nouser-core dep nueva: `blake3` (hash determinista de strings).
4385- `crates/modules/nouser/core/src/embed.rs`:
4386  - `EMBED_DIM = 32`. Estructura del vector:
4387    - dims  0..8:  blake3(extension)     → identidad de tipo
4388    - dims  8..16: blake3(parent_dir)    → identidad de contenedor
4389    - dims 16..24: blake3(file_stem)     → identidad léxica
4390    - dims 24..28: tamaño (log + flags)
4391    - dims 28..32: mtime (escala día + cíclicas)
4392  - **Tip clave**: bytes del hash se centran a `[-1, 1]` (no `[0, 1]`).
4393    Sin centrar, dos vectores hash random tendrían cosine ~0.75
4394    espuria; centrados, expectativa ≈ 0 entre no-relacionados.
4395  - APIs: `embed`, `cosine_similarity`, `centroid`, `cohesion`,
4396    `attraction_score`, `best_attraction`. `DEFAULT_ATTRACTION_THRESHOLD = 0.7`.
4397- `cluster::by_directory` ahora computa el centroide de cada Mónada
4398  (promedio de embeddings de los miembros, L2-normalizado) y lo guarda
4399  en `MonadManifest.centroid`. El centroide viaja al brahman-status vía
4400  `DataFacet.centroid` → ahora se ven los Vec<f32> reales por cada Mónada.
4401- bin nouser nuevo subcomando: `attract <dir> <file>`.
4402  - Escanea el dir, embeda el archivo objetivo, ranking de afinidad
4403    contra todas las Mónadas con centroide.
4404  - Marca 🧲 si la mejor supera el umbral, `·` si es la mejor pero
4405    debajo, espacio en blanco para el resto.
4406
4407Validación end-to-end:
4408
4409  $ nouser attract crates/core crates/modules/nouser/core/src/embed.rs
4410  ranking de atracción (cosine similarity):
4411    🧲  0.9058  [01K..] src  (11 archivos en crates/core/ente-brain/src)
4412        0.8984  [01K..] src  (6 archivos en crates/core/brahman-handshake/src)
4413        0.8918  [01K..] src  (5 archivos en crates/core/ente-zero/src)
4414        ...
4415
4416  $ nouser attract crates/core crates/modules/nouser/core/Cargo.toml
4417  ranking:
4418        0.3427  [01K..] graph  (7 archivos en crates/core/ente-zero/src/graph)
4419        ...
4420    (mejor score 0.3427 < umbral 0.7000 — el archivo no se 'pega')
4421
4422Tests: 20 en nouser-core (era 13, +7 de embed). Total acumulado: 73
4423(card 11, broker 15, handshake codec+tr 2 + integ 7, card-wit 4,
4424admin 0, nouser-card 7, nouser-core 20, ente-card 0).
4425cargo check --workspace: 0 errores, 0 warnings.
4426
4427Próximo: **Phase D** — `nouser-nous`, módulo aparte para LLM real.
4428Mock-nous determinista (basado en estos pseudo-embeddings) en
4429`BRAHMAN_BROKER_CONTEXT=test`; real-nous en `prod`. El switch lo hace
4430el broker via priority_contexts sin tocar nada más.
4431
4432### feat(nouser): Phase B-2 — daemon que publica Mónadas al Init
4433Cierra la unificación: el `nouser daemon` se sidecarea como Ente y
4434publica cada Mónada como su propia sesión Data. Un solo
4435`brahman-status` muestra procesos y datos en la misma lista, exactamente
4436como buscaba el diseño.
4437
4438Cambios:
4439
4440- `crates/modules/nouser/core/Cargo.toml`: deps nuevas `brahman-card`
4441  y `brahman-sidecar`.
4442- `crates/modules/nouser/core/src/bin/nouser.rs`: subcomando
4443  `daemon <dir>`.
4444  - Spawna un sidecar para el "engine" (`brahman.nouser_engine`,
4445    kind=Ente) — el ser que produce y administra Mónadas.
4446  - Scan + cluster del dir.
4447  - Para cada Mónada, llama `monad.to_brahman_card()` y spawnea un
4448    sidecar (kind=Data). Cada Mónada es una sesión brahman propia
4449    con su ULID estable.
4450  - Park del thread principal: los sidecars siguen pingueando.
4451
4452Validación end-to-end:
4453
4454  $ ente-zero &
4455  $ NOUSER_MIN_FILES=5 nouser daemon crates/core &
4456  $ brahman-status
4457
4458  Sessions (6):
4459    [ente]  ... brahman.nouser_engine  lifecycle=Daemon
4460    [data]  ... src    summary: 5 archivos en crates/core/brahman-admin/src
4461                       members: 5 (dispersion=0.00)
4462                       lens hint: code
4463    [data]  ... src    summary: 11 archivos en crates/core/ente-brain/src
4464                       ...
4465    [data]  ... graph  summary: 7 archivos en crates/core/ente-zero/src/graph
4466
4467El protocolo de presentación es uno solo: la Card. La función — anunciar
4468identidad, exponer metadata, ser descubierto — es idéntica para procesos
4469vivos y agrupaciones de datos. La UI lo ve como una lista uniforme.
4470
4471Costo conocido: cada Mónada consume un thread + tokio runtime
4472current_thread (legacy del sidecar API). Para muchas Mónadas (>50)
4473conviene consolidar en un único runtime con N tasks. Defer a Phase B-3.
4474
4475Pendientes propuestos:
4476- **B-3**: consolidar todos los sidecars en un único runtime tokio
4477  para no spawnear N threads.
4478- **C**: pseudo-embeddings + atracción por centroide.
4479- **D**: módulo `nouser-nous` para LLM, swappable por priority_contexts.
4480- **Polish**: labels con 2-3 componentes del path.
4481- **Crossreferencia**: que un Ente pueda anunciar "estoy procesando la
4482  Mónada X" y la Mónada anuncie "Ente Y me está procesando".
4483
4484cargo check --workspace: 0 errores, 0 warnings.
4485
4486### feat: Phase B-1 — unificación ontológica de Cards (Ente ↔ Data)
4487La Card es **el** protocolo de presentación del ecosistema, no sólo de
4488los procesos. Una Mónada Nouser y un Ente Brahman son ambos "entidades
4489que se presentan"; el consumidor (UI, broker, admin) discrimina por
4490`kind` cuando importa, pero todos hablan el mismo idioma.
4491
4492Cambios:
4493
4494- `brahman-card`:
4495  - `CardKind { Ente (default), Data }`. Conserva back-compat:
4496    Cards existentes son `Ente` por default.
4497  - `DataFacet { summary, keywords, centroid, member_count, dispersion,
4498    presentation_hint }`. Liviano para el wire — listas grandes
4499    (members, embeddings completos) se consultan al daemon dueño bajo
4500    demanda.
4501  - `Card.kind` y `Card.data: Option<DataFacet>` agregados. WireCard
4502    espeja, conversiones `From` propagan.
4503  - Default impl actualizado.
4504
4505- `brahman-broker::BrokeredCard`: propaga `kind` y `data` desde la Card
4506  registrada. No afecta el matching (sigue siendo por TypeRef +
4507  priority + pin_to); permite a observadores discriminar sin re-query.
4508
4509- `nouser-card`: depende ahora de `brahman-card`. Nuevo método
4510  `MonadManifest::to_brahman_card()` que proyecta:
4511  - id, label, lineage → directos.
4512  - payload Virtual, supervision Delegate, lifecycle Daemon (placeholder
4513    semántico — la Mónada no se ejecuta).
4514  - kind = Data.
4515  - data = Some(DataFacet) con summary, keywords, centroide,
4516    member_count, entropy → dispersion, y un `presentation_hint` derivado
4517    del `Lens` (`Code` → `"code"`, `Gallery` → `"gallery"`, etc.).
4518  - Test nuevo: `projects_to_brahman_card`.
4519
4520- `brahman-status`: cada sesión muestra ahora `[ente]` o `[data]` como
4521  prefijo. Para sesiones `data`, render adicional con summary, members
4522  + dispersion, keywords y lens hint.
4523
4524Resultado: la UI (yahweh, brahman-status, futuro explorer) ve una sola
4525lista uniforme. No tiene que saber si está mirando un proceso o un
4526cúmulo de datos — sólo lee el Card y se adapta por `kind`.
4527
4528Tests acumulados: 59 (card 11, broker 15, handshake codec+transport 2 +
4529integ 7, card-wit 4, admin 0, nouser-card 7, nouser-core 13).
4530cargo check --workspace: 0 errores, 0 warnings.
4531
4532Próximo: **Phase B-2** — bin `nouser daemon <dir>` que sidecarea cada
4533Mónada como una sesión brahman, publicándola al broker. Brahman-status
4534las verá junto a los entes.
4535
4536### feat(nouser): Phase A — mecanismo determinista de Mónadas
4537Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como
4538"imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los
4539casos sin tocar IA — sólo metadatos y heurísticas.
4540
4541Crates nuevos:
4542
4543- `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de
4544  Presentación de una Mónada — espejo conceptual de `brahman::Card`
4545  pero para datos, no para procesos runtime). Campos: id (Ulid),
4546  label, summary, centroid (vacío en Phase A), keywords, cardinality,
4547  entropy [0,1], dominant_lens, pins, members, timestamps,
4548  extensions (forward-compat). 6 tests de validación + JSON roundtrip.
4549- `crates/modules/nouser/core`: pipeline determinista.
4550  - `scanner`: walkdir → `Vec<FileEntry>` con metadatos (path, size,
4551    mtime, extension). Skipea hidden por default. Configurable max
4552    depth y follow_links.
4553  - `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos
4554    para promover a Mónada (configurable). Calcula keywords (top-N
4555    extensiones por frecuencia + alfabético), elige `Lens` dominante
4556    (Code/Gallery/Markdown/Database/Grid) según extensión más
4557    frecuente, computa entropía de Shannon normalizada [0,1].
4558  - `db`: `MonadDb` en memoria con índices BTreeMap files/monads y
4559    `resolve_members(monad_id)` que filtra IDs huérfanos. Phase B
4560    traerá persistencia.
4561  - bin `nouser`: subcomandos `scan <dir>`, `show <dir> <prefix>`,
4562    `json <dir>`. Env var `NOUSER_MIN_FILES` para tunear el threshold.
4563  - 13 tests (4 scanner + 6 cluster + 3 db).
4564
4565Demo end-to-end:
4566
4567  $ nouser scan crates
4568  scan: 255 archivos en crates, 19 mónadas (min_files=3)
4569    [01KR4C13]  src                             card=12  ent=0.00  lens=Code
4570             keywords: rs
4571    [01KR4C13]  tests                           card=14  ent=0.00  lens=Code
4572             keywords: rs
4573    [01KR4C13]  fixtures                        card=5   ent=0.00  lens=Grid
4574             keywords: rhai
4575    ...
4576
4577  $ nouser show crates 01KR4C
4578  Monad 01KR4C1370DVF6NMTW6SECNXAF
4579    label:       src
4580    summary:     4 archivos en crates/modules/nouser/core/src (ext: rs)
4581    cardinality: 4
4582    entropy:     0.0000
4583    lens:        Code
4584    members (4):
4585            4132 bytes  crates/modules/nouser/core/src/db.rs
4586            ...
4587
4588Pendientes para próximas fases (anotados, no urgentes):
4589- **Phase B**: bin `nouser daemon` que sidecarea a brahman-init
4590  declarando flows (`scan-request:json` → `monad-update:json`).
4591- **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size
4592  a 32-d) + atracción por centroide via cosine similarity. Implementa
4593  el "imán" sin LLM.
4594- **Phase D**: módulo `nouser-nous` aparte para el LLM real
4595  (Llama/ONNX). En `priority_contexts.test` el Init pinea a
4596  `mock-nous` (embeddings determinísticos); en `prod` a `real-nous`.
4597- **Polish**: labels de Mónada incluir 2-3 componentes del path para
4598  desambiguar `src/` repetidos en monorepo.
4599
4600Workspace: 0 errores, 0 warnings. Tests acumulados: 58
4601(card 11, broker 15, handshake codec+transport 2 + integ 7,
4602card-wit 4, admin 0, nouser-card 6, nouser-core 13).
4603
4604### feat(broker): priority contexts — biases per-contexto operativo
4605- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
4606  declara un override per-contexto.
4607- `Card.priority_contexts: BTreeMap<String, ContextBias>` y mismo en
4608  `WireCard` (cruza el wire). Las conversiones `From` lo propagan.
4609- `BrokerConfig.current_context: Option<String>`. Cuando el broker corre
4610  bajo un contexto y una Card declara biases para ese nombre, se aplican:
4611  - Como **consumidor**: `pin_to` sobreescribe el `Flow.pin_to` estático.
4612  - Como **productor**: `priority_offset` se suma a la priority base
4613    (clamp en `[Low=0, Critical=3]`) para el ranking.
4614- `BrokeredCard` propaga `priority_contexts`. `find_producer_for` usa
4615  `effective_priority(card)` y `effective_pin(card, input)` antes de
4616  los tiebreaks.
4617- `brahman-admin::AdminConfig.current_context` + `StatusSnapshot.current_context`
4618  espejan el contexto activo. `brahman-status` lo imprime como
4619  `Context: <nombre>` justo debajo de `Init: ...`.
4620- `ente-zero` lee `BRAHMAN_BROKER_CONTEXT` env var y la propaga al
4621  broker y al admin. Sin var, biases per-contexto inactivos.
4622- 4 tests nuevos en brahman-broker:
4623  `context_priority_offset_lifts_producer_above_alphabetic_winner`,
4624  `context_pin_to_overrides_static_pin`, `unknown_context_no_op`,
4625  `priority_offset_clamps_to_critical`.
4626- Validación end-to-end: `BRAHMAN_BROKER_CONTEXT=test ente-zero` →
4627  `brahman-status` muestra `Context: test`.
4628
4629### feat(card): WireCard + extensions — forward-compat sin romper postcard
4630- `Card.extensions: BTreeMap<String, serde_json::Value>` restaurado con
4631  `#[serde(flatten, default, skip_serializing_if = is_empty)]`. Los
4632  campos JSON/TOML desconocidos sobreviven el roundtrip de archivos.
4633- Nuevo `WireCard`: proyección postcard-friendly (sin `extensions`,
4634  `genesis: Vec<WireCard>` recursivo). Conversiones `From<Card>` y
4635  `From<WireCard>` con descarte/recreación de extensions.
4636- `brahman-handshake::Hello.card` pasa de `Card` a `WireCard`. Client
4637  hace `card.into()` antes de enviar; Server hace `hello.card.into()`
4638  para volver a Card antes de validar/registrar.
4639- 3 tests nuevos en brahman-card:
4640  `extensions_preserved_in_json_roundtrip`,
4641  `wire_card_roundtrip_strips_extensions`,
4642  `wire_card_postcard_friendly` (postcard encode/decode efectivo).
4643- brahman-card gana `postcard` como dev-dep para el último test.
4644- Contrato documentado: extensions = anotaciones locales que NO cruzan
4645  al Init; sólo viven en archivos.
4646
4647### `9420eae` chore: limpia warnings dead-code en arje (commit del usuario)
4648- `ente-zero/src/events.rs`: `#![allow(dead_code)]` a nivel módulo —
4649  es vocabulario de eventos con variantes/campos reservados para flujos
4650  no cableados aún (CapabilityRequested, ShutdownReason::Signal,
4651  CapabilityGrant::{Granted, Denied, QuotaExceeded}, ExitStatus
4652  fields).
4653- `ente-zero/src/graph/mod.rs`: comentado el re-export ahora innecesario
4654  de `SHUTDOWN_GRACE`. `DEFAULT_GRANT_TTL` con `#[allow(dead_code)]`
4655  + nota "reservado para capability granting".
4656- `ente-zero/src/graph/capabilities.rs`: `renew_grant` con
4657  `#[allow(dead_code)]` (capability renewal pendiente).
4658- `ente-kernel/src/surface.rs`: drop de `use anyhow::Context` (no se
4659  usaba).
4660- `ente-hostnamed-compat/src/main.rs`: drop de `Connection` (no se
4661  usaba).
4662- `ente-polkit-compat/src/main.rs`: `PolicyDecision.source` con
4663  `#[allow(dead_code)]` (sólo aparece en `Debug` para logging).
4664- `cargo check --workspace`: 17 warnings → 0.
4665
4666### feat(sidecar): WIT al sidecar — módulos conscientes vivos
4667- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
4668  `PartialEq`, `Eq` para cruzar el wire postcard.
4669- `brahman-handshake::Hello` lleva `wit: Option<WitInterface>`. Server
4670  usa `ResolvedCard::from_conscious` cuando viene presente, `from_agnostic`
4671  cuando no.
4672- `brahman-handshake::Client::connect` queda como wrapper agnóstico de
4673  `connect_with(path, card, wit: Option<WitInterface>)`.
4674- `brahman-broker::Broker::register` ahora toma `Option<WitInterface>`
4675  como tercer arg. `BrokeredCard` guarda el wit. 25 sitios de tests
4676  actualizados con `, None`.
4677- `brahman-sidecar::SidecarConfig` con campo `wit`. Helpers nuevos:
4678  `SidecarConfig::new(card).with_wit(wit)` y `spawn_conscious(card, wit)`.
4679  El log `attached` reporta `conscious=true|false`.
4680- `brahman-status` muestra marker 🧠 + sección `wit:` (package/world,
4681  imports, exports) por sesión consciente.
4682- Example nuevo `crates/shared/brahman-sidecar/examples/presence-conscious.rs`:
4683  toma label + path .wit (default `shared_wit/protocol.wit`), parsea
4684  con brahman-card-wit, spawna sidecar consciente.
4685- Validado end-to-end:
4686  ```
4687  $ presence-conscious demo.conscious shared_wit/protocol.wit &
4688  $ brahman-status
4689  Sessions (1):
4690    01K... demo.conscious 🧠  lifecycle=Daemon
4691        wit: brahman:protocol@0.1.0 / module
4692             imports: types, handshake, lifecycle
4693             exports: run
4694  ```
4695
4696### feat(core): brahman-card-wit — extractor opcional de contratos WIT
4697- Crate nuevo `crates/core/brahman-card-wit` con `wit-parser = "0.230"`.
4698- API: `parse_wit(source)` y `parse_wit_file(path)` devuelven
4699  `Vec<WitInterface>` (uno por `world` declarado).
4700- Interfaces importadas/exportadas (no sólo funciones) se resuelven
4701  por nombre via `resolve.interfaces[id].name`.
4702- Example `crates/core/brahman-card-wit/examples/brahman-wit-info.rs`
4703  CLI: `brahman-wit-info shared_wit/protocol.wit` → lista paquete,
4704  worlds, imports y exports.
4705- 4 tests: inline, archivo real (`shared_wit/protocol.wit`), parse
4706  error, world vacío.
4707- Validado contra `protocol.wit`: detecta worlds `module` y
4708  `admin-host` con sus imports/exports correctos.
4709
4710### `7b589b8` chore: agrega CHANGELOG.md retroactivo
4711- `CHANGELOG.md` en la raíz con los 11 commits previos documentados
4712  acción por acción. A partir de este punto, cada cambio sustantivo
4713  actualiza también este archivo en el mismo commit.
4714
4715### `8a83a26` feat(handshake): notificación push de matches
4716- Frame `MatchEvent { kind: Available | Lost, ... }` añadido al protocolo.
4717- `Session::run_post_handshake` usa `tokio::select!` para multiplexar
4718  reads del cliente y un canal `mpsc` push del server.
4719- Server: `SessionTxTable` (Arc<Mutex<HashMap<SessionId, Sender<Frame>>>>)
4720  y `LastMatches` para diff por sesión. `broadcast_match_diffs` corre
4721  tras cada `register` y `unregister`, emite sólo los cambios.
4722- Capacity del canal push: 32 (ephemeral, `try_send` non-blocking).
4723- Client: `VecDeque<MatchEvent>` interno, `take_event()` (non-blocking)
4724  y `await_event(timeout)`. `ping()` ahora drena MatchEvents intermedios
4725  hasta encontrar el Pong.
4726- Example `crates/core/brahman-handshake/examples/subscriber.rs`.
4727- Test `match_event_pushed_on_producer_arrival` (handshake integ 6→7).
4728
4729### `70a7a0d` feat: segundo módulo (nakui) + admin API + brahman-status
4730- Crate nuevo `crates/shared/brahman-sidecar` (DRY del thread + tokio +
4731  ping loop). API: `spawn(card)` / `spawn_with_handle(config)`.
4732- `nakui` cmd_run llama `brahman_sidecar::spawn` antes de `run_server`.
4733  Card: lifecycle Daemon, supervision Restart, flow `command` (json) /
4734  `report` (json).
4735- Crate nuevo `crates/core/brahman-admin` con `StatusSnapshot` JSON
4736  line-delim, `AdminServer` y `client::query`.
4737- ente-zero levanta también el AdminServer en `primordial_loop`.
4738- Example `crates/shared/brahman-sidecar/examples/presence.rs`
4739  (módulo dummy long-lived parametrizable por label).
4740- Example `crates/core/brahman-admin/examples/brahman-status.rs`
4741  (CLI que pretty-printa el snapshot).
4742- `brahman-broker`: `BrokeredCard` ahora incluye `lifecycle`. `Endpoint`
4743  y `Match` derivan `Serialize`/`Deserialize`. Nuevo `Broker::cards()`
4744  iterador.
4745- `brahman-card`: `pub use ::ulid` para que módulos no dependan de ulid.
4746- yahweh-shell migrado al sidecar compartido (96→53 LOC).
4747
4748### `595f68e` feat(yahweh-shell): primer módulo brahman vivo
4749- yahweh-shell spawnea sidecar antes de `Application::new()`.
4750- Card declarada: label `brahman.ui_engine`, lifecycle Widget,
4751  supervision Delegate, payload Virtual, flow input `render-data`
4752  (json) / output `user-intent` (json).
4753- Sidecar en thread aparte con tokio current_thread runtime,
4754  desacoplado del runtime GPUI.
4755
4756### `df9d10c` feat(ente-zero): enchufa el handshake server al Init real
4757- ente-zero levanta `brahman_handshake::server::Server::bind` en
4758  `primordial_loop` después del ente-bus, con degradación grácil
4759  si bind falla (mismo patrón que uevents).
4760- Nuevo módulo `brahman-handshake/src/transport.rs`: helper
4761  `default_socket_path()` con resolución `BRAHMAN_INIT_SOCKET` →
4762  `XDG_RUNTIME_DIR` → `TMPDIR`.
4763- Example `crates/core/brahman-handshake/examples/probe.rs`.
4764- Validación end-to-end manual: probe contra ente-zero vivo
4765  imprime `HelloAck: session=... init_attached=true`.
4766
4767### `07d77a3` feat(handshake): integra el broker con el ciclo de sesiones
4768- `ServerConfig` acepta `Option<Arc<Mutex<Broker>>>`.
4769- `register_session` indexa la Card en el broker y la `SessionRegistry`
4770  antes de emitir HelloAck.
4771- `Session::handle` refactor a `do_handshake → run_post_handshake →
4772  cleanup` con cleanup unificado (broker + sessions).
4773- Tests integ nuevos: `broker_registers_and_unregisters_with_session`
4774  y `broker_matches_two_live_modules`.
4775- Fix colateral: `brahman-card::TypeRef` pasa de internally-tagged
4776  (`#[serde(tag = "kind")]`) a externally-tagged. Postcard no soporta
4777  internally-tagged en formatos no self-describing. JSON cambia de
4778  `{"kind":"primitive","name":"x"}` a `{"primitive":{"name":"x"}}`.
4779
4780### `5091106` feat(core): brahman-broker — matching híbrido
4781- Crate nuevo `crates/core/brahman-broker`.
4782- 3 estrategias de matching: `Exact`, `Structural`, `ExactThenStructural`
4783  (default). Devuelven `Match::via` con la estrategia que ganó.
4784- Override `pin_to`: el consumer pide un productor por label; si la
4785  pista no resuelve, cae en type-search.
4786- Tiebreak por `Card.priority` desc, luego `label` asc (estable y
4787  determinista).
4788- API: `register`, `unregister`, `find_producer_for`, `all_matches`,
4789  `cards`, `sessions`, `len`, `is_empty`.
4790- 11 tests (matching, pin_to, priority, no-self-loops, all-matches).
4791
4792### `814390f` feat(core): brahman-handshake — protocolo runtime
4793- Crate nuevo `crates/core/brahman-handshake` con server y client
4794  Rust↔Rust sobre Unix socket.
4795- Frames length-prefixed (4 bytes LE) + cuerpo postcard.
4796- Mensajes: `Hello`, `HelloAck`, `Ping`, `Pong`, `Farewell`, `Error`.
4797- `MAX_FRAME_BYTES = 4 MiB` para evitar reservas absurdas.
4798- Tradeoff: drop `extensions`/`extra` de Card por incompat
4799  postcard ↔ `serde_json::Value`. Forward-compat queda en
4800  `schema_version` + `protocol_version` negotiation.
4801- 4 tests integ + 1 unit en codec.
4802
4803### `ed0e973` refactor(arje): migra ente-card a re-export de brahman-card
4804- `ente-card/src/lib.rs` reescrito como crate-shim de re-export
4805  (327 LOC → 25 LOC).
4806- `EntityCard` ≡ `brahman_card::Card` por type alias.
4807- `ente-card/Cargo.toml`: deps reducidas a `brahman-card`.
4808- `Card` impl `Default` (Ulid::nil(), label vacío) para que
4809  `..Default::default()` funcione en struct-literals.
4810- 4 sitios en `ente-zero/src/seed.rs` actualizados con
4811  `..Default::default()` para los campos aditivos.
4812- Los 21 consumidores arje compilan sin tocar fuente.
4813
4814### `0feba74` feat(core): brahman-card — Tarjeta canónica híbrida
4815- Crate nuevo `crates/core/brahman-card`.
4816- Hereda de arje: `id: Ulid`, `lineage`, `Capability` tipado,
4817  `Payload::{Wasm, Native, Virtual, Legacy}`, `SomaSpec`
4818  (namespaces, cgroups, rlimits, cpu_affinity), `Supervision`
4819  (Restart con backoff, OneShot, Delegate), `genesis` recursivo.
4820- Aditivo brahman: `Permissions` enumerados (`NetworkingPolicy`,
4821  `FsPolicy`, `IpcPolicy`), `Lifecycle` ortogonal a Supervision,
4822  `Priority` de scheduling, `Flows` con `TypeRef` discriminado
4823  (Primitive | Wit), `pin_to` opcional.
4824- `TrustLevel` derivado de `Permissions` (no declarado).
4825- `ResolvedCard { card, wit: Option<WitInterface>, trust }`.
4826- Soporta JSON (canónico) + TOML (auto-detectado por extensión).
4827- 8 tests incluido `arje_seed_format_compatible` que valida que
4828  el JSON de arje sigue parseando con defaults para los aditivos.
4829
4830### `4d50bfc` chore: absorbe nakui (ERP matemático) en modules/nakui
4831- `~/nakui` → `crates/modules/nakui/{core,modules}`.
4832- `core/`: el crate `nakui-core` con 4 bins (nakui, demo,
4833  inventory_demo, sales_demo) y tests.
4834- `modules/{inventory,sales,treasury}/`: data declarativa
4835  (`nsmc.json`, `schema.k`, `morphisms/`) que el crate consume.
4836  No son crates Cargo.
4837- Deps directas (no `workspace = true`): thiserror v1, surrealdb,
4838  rhai, petgraph. No conflicto con el resto del workspace.
4839
4840### `53dbdf0` chore: monorepo inicial con arje + minga + yahweh absorbidos
4841- 45 crates absorbidos en 4 ejes:
4842  - `crates/core/`: 24 crates de arje (Init systemd-compatible:
4843    `ente-card`, `ente-zero`, `ente-kernel`, `ente-bus`, `ente-cas`,
4844    `ente-soma`, `ente-wasm`, `ente-snapshot`, `ente-brain`,
4845    `ente-echo`, `ente-policy-provider`, + 12 `*-compat`).
4846  - `crates/modules/semantic_dht/`: 5 crates de minga (`minga-core`
4847    con AST/CAS/MST, `minga-p2p` con libp2p Kad, `minga-store`,
4848    `minga-vfs`, `minga-cli`).
4849  - `crates/modules/ui_engine/`: 11 crates de yahweh (libs/{core,
4850    theme, bus, providers}, widgets/{tree, splitter, tabs, tiled,
4851    container_core, text_input}).
4852  - `crates/apps/`: 5 crates de yahweh (file_explorer,
4853    database_explorer, text_viewer, image_viewer, yahweh-shell).
4854- `shared_wit/protocol.wit` con handshake/lifecycle inicial.
4855- `Cargo.toml` unificado: thiserror bumped a 2 (transparente para
4856  arje), tokio "full", paths intra-workspace de yahweh redirigidos.
4857- `cargo check --workspace`: 0 errores (sólo dead-code warnings
4858  preexistentes en ente-zero).

CHANGELOG.md

Changelog

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-10

fix(nakui-core): schema_bundle_hash debe reflejar el contenido real del schema

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).

feat(yahweh-widget-app-header): promover el header standard de explorers

Iter 16. Patrón con 4 consumers idénticos: nakui-explorer, nouser-explorer, minga-explorer, brahman-broker-explorer todos declaraban un header flex_row + flex_grow(label) + theme_switcher + bg(panel) + border-bottom + text_size(14) + padding(16/12). Ahora es 1 línea.

Crate nuevo crates/modules/ui_engine/widgets/app-header/ (yahweh-widget-app-header):

Migración de los 4 consumers:

Total ahorro: ~50 líneas de código UI hardcoded en consumers. Cambios visuales en el header (padding, border, text_size) ahora viven en un solo lugar.

Tests stack: 3 nuevos en app-header; suites de los 4 consumers intactas. Todo verde.

Decisión: el sidebar header del MetaApp (que también incluye theme_switcher) NO se migra — es un header de sidebar, no de app top, y tiene styling distinto (px(12/10/13), sin bg/border-bottom porque ya está dentro del panel). Diferente patrón → diferente widget si emerge segundo consumer.

feat(yahweh-widget-stat-card): promover el patrón stat card como widget

Iter 15. El patrón "tarjeta de dashboard con border-l accent + label + valor grande + descripción + listing opcional" tenía 2 consumers (minga-explorer y brahman-broker-explorer); ahora vale extraer al stack yahweh para reusabilidad y mantenimiento single-place.

Crate nuevo crates/modules/ui_engine/widgets/stat-card/ (yahweh-widget-stat-card):

Cambios consumer:

Tests stack: nuevos 3 del widget. Suites de los 2 consumers intactas (4 minga-explorer, 2 broker-explorer). Stack total ~120 verdes (varía por compilation cache).

Beneficio operativo:

Pequeña simplificación documentada: el sub-header del listing pasa de "recent (N de TOTAL):" a "recent (N):". El "TOTAL" ya no se calcula porque el widget no lo conoce — el caller que quiera mostrarlo lo formatea en el label/value (ej. label "Nodos AST (5 de 247)"). Acceptable trade-off por la reusabilidad genérica.

feat(brahman-broker-explorer): nueva app probe del broker brahman

Iter 14. Cierra otro frente: visibilidad del broker brahman (el broker handshake que matchea Cards consumer/producer). Hasta ahora no había forma de "ver" si el broker estaba up sin invocar otro binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3 estados claros.

Crate nuevo crates/apps/brahman-broker-explorer/:

Smoke run del binario verificado: bootstrap completo OK, panic esperado en open_window por falta de display.

Beneficio operativo:

Apps GUI integradas al stack themed: 5 (nakui-ui, nakui-explorer, nouser-explorer, minga-explorer, brahman-broker-explorer).

Limitaciones documentadas:

feat(yahweh-theme): persistencia de la preferencia de theme entre runs

Iter 13. El theme switcher ya cambiaba el chrome en runtime, pero al cerrar y reabrir la app el theme volvía a Nebula default. Ahora el name del theme se persiste en $XDG_CONFIG_HOME/yahweh/theme (default ~/.config/yahweh/theme) y se restaura al boot.

Cambios en yahweh-theme:

5 tests nuevos (persistence_tests):

Tests stack: ~5 nuevos en yahweh-theme. Todos los downstream (nakui-ui, *-explorer) compilan sin tocar nada — la API pública de Theme::install_default y Theme::set no cambió shape. Smoke run del binario verificado: bootstrap OK, panic esperado sin display.

Beneficio operativo:

feat(minga-explorer): listings de items recientes en cada stat card

Iter 12. Hasta ahora minga-explorer mostraba sólo counts (3 números). Ahora cada stat card muestra también un sample de los items dentro: hashes truncados de los 5 primeros nodos AST (con su kind), atestaciones (content_hash ← did_short) y claves MST. Mucho más útil para debugging que el "tengo N items".

Cambios en minga-explorer:

Tests: 2 → 4 (+2 sanity de los nuevos defaults + del short_hash).

Beneficio operativo:

Limitación documentada: los "recent" no son cronológicos — sled ordena lexicográfico por hash. Para timeline real, agregar timestamp al schema (cambio breaking del store, scope futuro).

feat(minga-explorer): nueva app dashboard del repo Minga sobre stack yahweh

Iter 11. Cierra el último frente identificado: integración del módulo Minga (VCS semántico P2P) al ecosistema GUI. Antes Minga sólo tenía CLI (minga init/status/ingest/listen/sync/watch). Ahora hay un dashboard GPUI que muestra los counts del repo en vivo, sobre el mismo stack themed que las otras dos apps explorer.

Crate nuevo crates/apps/minga-explorer/:

Workspace: nueva entry en members[].

Smoke run del binario verificado: bootstrap completo OK, panic esperado en open_window por falta de display.

Beneficio operativo:

Apps GUI integradas al stack themed: 4 (nakui-ui, nakui-explorer, nouser-explorer, minga-explorer).

feat(nouser-explorer): integración al stack yahweh themed

Iter 10. nouser-explorer (la app paralela a nakui-explorer para ver Mónadas via daemon nouser) tenía colors hardcoded idénticos al patrón previo. Aplico el mismo refactor que se hizo para nakui-explorer en iter 4: instala el theme global, migra chrome a slots, usa los widgets banner_themed / card_themed / theme_switcher.

Cambios en nouser-explorer:

Tests: workspace stack intacto. nouser-explorer no tiene tests propios (siempre fue una vista live del daemon, sin lógica testable separada).

Beneficio operativo: las dos apps explorer del repo (nakui-explorer para event log + nouser-explorer para Mónadas) ahora comparten la misma paleta themed + el mismo control de switcher. Si un usuario las corre lado a lado, la consistencia visual emerge sola.

feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed

Iters 8-9 combinadas. Tres mejoras pequeñas que cierran la integración del theme:

1. Caret blinking en text-input (yahweh-widget-text-input):

2. Slots ornament en yahweh-theme (5 nuevos):

3. MetaApp ornament cleanup (yahweh-widget-meta-form):

Antes de este commit MetaApp tenía la paleta principal themed (iter 5) pero el ornament secundario (hovers, separators, botones inline) seguía hardcoded. Ahora el theme switcher cambia absolutamente todo el chrome del MetaApp en runtime.

Tests: 117 verdes (sin cambios numéricos, pero downstream sigue compilando). Smoke run de nakui-ui: bootstrap completo OK.

Limitación restante: nouser-explorer todavía no migra al stack yahweh themed — patrón idéntico a nakui-explorer aplicado pero más nuevo. Próxima iter.

feat(yahweh-widget-text-input): focus-aware border + caret sólo on focus

Iter 7 (mini-iter — el text-input ya estaba themed, faltaba sólo el polish de focus visibility). Antes el border era siempre accent_strong y el caret | siempre estaba presente — imposible distinguir cuál input está activo en un form con varios fields.

Cambios en yahweh-widget-text-input:

Sin cambios en API pública — todo es interno al render. El binario no requiere migración.

Tests: sin cambios (los tests del crate son struct constructors, no rendering). Tests downstream del widget (yahweh-widget-meta-form, nakui-ui) siguen verdes — el cambio es backward compatible.

Beneficio operativo:

Limitación pendiente: el caret | literal no parpadea (no hay animation timer). Cuando emerja la necesidad, agregar via cx.spawn con un loop de toggle. Por ahora el caret estático on focus es suficiente signal.

feat(yahweh-widget-theme-switcher): control para ciclar themes en runtime

Iter 6. Cierra el ciclo del theme: ya teníamos paleta themed + widgets que la consumen, faltaba el control UI para rotar entre presets en vivo. Ahora hay un botón yahweh que muestra el theme actual y al click avanza al siguiente. nakui-ui y nakui-explorer lo incrustan en sus headers — un click cambia toda la paleta.

Crate nuevo: crates/modules/ui_engine/widgets/theme-switcher/ (yahweh-widget-theme-switcher):

Migración de consumers:

Tests stack: 115 → 117 (+2 del switcher). Cada crate compila individualmente.

Beneficio operativo:

Decisión técnica: el handler usa Theme::set(cx, ...) que invalida el global. GPUI marca todos los views como dirty y re-renderea — los widgets que leen Theme::global en su render ven el nuevo automáticamente. No requiere cx.observe_global explícito en cada widget consumidor.

Limitación: TextInput entities ya creadas no se actualizan visualmente si el theme cambia los colors del input bg/border (esos colors están hardcoded en yahweh-widget-text-input). Migrar text_input al theme es una iter futura — bajo scope porque actualmente vive suficientemente bien con sus defaults dark.

feat(yahweh-widget-meta-form): paleta del chrome migrada a Theme::global(cx)

Iter 5 de integración. El MetaApp::render tenía 7 vars locales con colors hardcoded (bg/panel/border/text/text_dim/accent/ accent_active) que se pasaban a las funciones internas (render_sidebar/render_main/render_list/render_form/ render_entity_ref_selector). Ahora salen del Theme::global(cx) que el binario shell instala al boot. El confirm_delete_banner también usa themed_colors(Banner::Warning) / themed_colors(Banner::Error) para sus colors base.

Cambios en MetaApp::render:

Cambios de firma (internas, no API público):

Cambios en render_confirm_delete_banner:

Lo que NO migra esta iter (queda como ornament hardcoded; iter futura si emerge la necesidad):

Estos son detalles ornamentales que un theme switcher real querría integrar; los aislo para una pasada futura cuando esté claro qué slots semánticos del theme conviene agregar (ej. bg_row_selected distinto de bg_row_hover, accent_destructive, etc.).

nakui-ui shell ya instalaba Theme::install_default(cx) desde la iter pasada — sigue siendo el contract entre el shell y el widget. Smoke test del binario verificado: bootstrap completo OK, panic esperado en open_window sin display.

Tests stack: 115 verdes (sin cambio — los tests del widget no acceden al render).

Beneficio operativo:

feat(yahweh): theme integration en banner + card + nakui-explorer consume themed

Iter 4 de la integración. Los widgets banner y card ahora ofrecen variants _themed(cx, ...) que leen Theme::global(cx). Las versiones sin theme se preservan para apps sin theme global. nakui-explorer migra a versiones themed + Theme::install_default al boot — el chrome hardcoded del explorer (5 variables let bg = rgb(...)) sale del theme.

Cambios en yahweh-widget-card:

Cambios en yahweh-widget-banner:

Migración de nakui-explorer:

Distribución de tests: 112 → 115 (+3 del banner derivation). Workspace stack pasó por la migración sin errores.

Beneficio operativo:

Decisiones:

Próximas integraciones pendientes:

feat(yahweh-widget-card): container card-shape compartido para timeline entries

Iteración 3 de la integración nakui ↔ yahweh. El "card visual" pattern (padding consistente + rounded + flex_col + gap) que vivía duplicado en cada timeline entry de nakui-explorer ahora es un widget yahweh reusable. Sin acoplamiento a colores: el caller decide bg/border/accent.

Crate nuevo: crates/modules/ui_engine/widgets/card/ (yahweh-widget-card):

Migración de nakui-explorer:

Tests stack: 111 → 112 verdes (+1 del crate card). Cada crate afectado compila y testea individualmente.

Beneficio operativo:

feat(yahweh-widget-banner): widget compartido para toasts/errores cross-app

Patrón visual común a yahweh-widget-meta-form (toast success + error_banner) y nakui-explorer (error_banner): un div con bg

Crate nuevo: crates/modules/ui_engine/widgets/banner/ (yahweh-widget-banner):

Migración de consumers:

Tests stack: 109 → 111 verdes (+2 del crate banner).

Beneficio operativo:

Próximo candidato natural: el confirm_delete_banner de MetaApp es Banner::Warning + 2 botones embedded. Cuando emerja un segundo consumer de modal-style banners, extraer un widget compositivo arriba del Banner base.

feat(yahweh): MockBackend público + tests E2E del widget con gpui::TestAppContext

Cierra el ciclo de testabilidad del widget metainterfaz. Hasta ahora los tests del trait MetaBackend vivían como impl privada en backend.rs; el widget no tenía forma de testear handlers reales sin levantar NakuiBackend (que depende de event log + Rhai + nakui-core). Ahora el mock es público y los tests del widget lo consumen con TestAppContext.

Cambios en yahweh-meta-runtime:

Cambios en yahweh-widget-meta-form:

Helpers de tests:

Distribución de tests:

Beneficio operativo:

Limitaciones:

feat(yahweh-meta-runtime): promover short_hash y preview_value desde nakui-explorer

Continúa la integración de las apps nakui al stack yahweh. Los helpers visuales que nakui-explorer tenía locales y son reusables suben a yahweh-meta-runtime/format para que cualquier app pueda consumirlos sin duplicar.

Cambios en yahweh-meta-runtime:

Migración de nakui-explorer:

Tests:

Beneficio operativo: 3 helpers visuales centralizados. Cualquier app nueva que muestre UUIDs/hashes/JSON-previews los importa sin re-implementar la heurística de truncamiento.

Pendiente arquitectural: el render del card timeline en nakui-explorer (border-l-4 colored + flex_col + texto en niveles) es un pattern reusable que también aparece en yahweh-widget-meta-form (render_list filas). Cuando aparezca un tercer consumer de ese pattern se extrae a un widget yahweh.

feat(brahman-cards): templates Nickel canónicos para cada body kind

Materializa el patrón "import + override" del brazo: hasta ahora BRAHMAN_CARDS_TEMPLATES_DIR existía como mecanismo pero el repo no shippeaba ningún template. Ahora hay 3 templates basic (uno por body kind del CardBody) bajo crates/core/brahman-cards/templates/:

Cada field override-able marcada | default (sin eso Nickel rebota merge de strings/numbers no-iguales).

API nueva en lib.rs:

5 tests E2E (tests/templates.rs) que cubren:

Helper de test with_canonical_templates(F) setea/restaura el env localmente; cada test single-thread-safe.

Tests suite brahman-cards: 26 → 31 verdes (+5). El resto del workspace intacto.

Beneficio operativo:

Limitaciones:

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:

Cambios en schemas de módulos:

Cambios en tests:

Cambios documentales:

Tests:

Beneficios:

Limitaciones / decisiones:

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).

refactor(yahweh): Fase 2c — extracción del widget al crate yahweh-widget-meta-form

Cierra el refactor de UI: el widget render (forms, lists, modal de delete, EntityRef selector, sidebar, key handlers) deja de vivir en el binario nakui-ui y pasa a un crate yahweh nuevo, genérico sobre MetaBackend. nakui-ui queda como un shell de bootstrap de 424 líneas.

Crate nuevo: crates/modules/ui_engine/widgets/meta-form/ (yahweh-widget-meta-form):

Cambios en nakui-ui (shell):

Distribución final del refactor yahweh:

Cada crate compila individualmente. El widget consume el trait sin saber qué backend hay debajo; nakui-ui provee el trait wireado a nakui-core; cualquier futuro shell (mock para tests, otro stack de storage) puede reusar el widget sin cambio.

Lo que NO hace Fase 2c:

Pendientes:

  1. KCL → Nickel: kcl_wrapper en nakui-core reemplazado por evaluación de Nickel contracts. Migrar los 3 schemas .k de sales/inventory/treasury a .ncl.
  2. card.k eliminado (REFERENCE ONLY documentado en su header).

refactor(yahweh): Fase 2b — MetaBackend trait + NakuiBackend + MetaUi consume el backend

Materialización del trait que diseñamos en charla. Tres pasos combinados en un solo commit:

Step A — trait + WriteOutcome en yahweh-meta-runtime:

Step B — NakuiBackend en nakui-ui/src/backend.rs:

Step C — MetaUi consume el backend:

Distribución de tests post-refactor:

Build: cada crate compila individualmente.

Nota sobre Fase 2b/c estado:

Pendientes:

  1. Fase 2c: extraer widget render al crate yahweh (yahweh-widget-meta-form o similar) — MetaApp<B: MetaBackend> genérico, nakui-ui queda como ~50 líneas de shell con MetaApp::<NakuiBackend>::new(...).
  2. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts.
  3. card.k eliminado (REFERENCE ONLY).

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):

Cambios en nakui-ui:

Distribución de tests:

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:

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 (entities, menús, listas, formularios, acciones) vivía bajo crates/modules/nakui/ui-schema/ y se llamaba nakui-ui-schema — un nombre que sugería acoplamiento con Nakui que en realidad no existe (el crate sólo depende de serde/serde_json/thiserror). Lo movemos a yahweh para que sea consumible por cualquier app de UI metadata-driven sin hacer pasar la dep "rara" por nakui.

Cambios mecánicos:

Cambios documentales:

Tests:

Lo que NO hace Fase 1:

Pendientes (orden):

  1. Fase 2: extraer widgets render (form/list/modal/EntityRef selector + helpers parse_field_value/render_value/etc.) a un nuevo crate yahweh-widget-meta-form (o nombre similar).
  2. Fase 3: trait MetaBackend + thin shell — nakui-ui queda reducido a una impl de backend wireada a nakui-core.
  3. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts.
  4. card.k eliminado (REFERENCE ONLY).

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:

Cambios en brahman-cards:

Cambios en nakui-ui:

Tests totales:

Lo que NO cambió:

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(brahman-cards): Nickel reader + templates con merge nativo (V2)

Sigue al V1 (readers JSON). Ahora el brazo acepta inputs .ncl: los evalúa via nickel-lang 2.0, exporta a JSON, y dispatcha por los mismos readers JSON estándar. Un .ncl puede producir cualquier CardBody siempre que su shape sea reconocida. Los templates funcionan con los import + & merge nativos de Nickel — el brazo no inventa una mecánica paralela.

Cambios:

Convención obligatoria de templates (documentada en nickel_eval.rs): las fields que el usuario va a sobrescribir deben marcarse | default (o | optional). Sin ese marker Nickel rechaza el merge de strings/numbers no-iguales con la misma prioridad. Patrón canónico:

# template ui_module_basic.ncl
{
  id | String | default = "TEMPLATE_ID",
  label | String | default = "TEMPLATE_LABEL",
  ...
}

# uso concreto
let base = import "ui_module_basic.ncl" in
base & { id = "my_id", label = "Mi Label" }

9 tests nuevos en tests/nickel.rs:

22 tests en total en brahman-cards (13 JSON V1 + 9 Nickel V2). Workspace build verde tras la dep nueva.

Lo que NO hace V2 (sigue pendiente):

Pendientes para próximos commits (orden):

  1. Migrar consumers (nakui-ui consume brahman_cards::load_card).
  2. Yahweh refactor: lift del MetaUi runtime a crates/modules/ui_engine/.
  3. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts; los 3 schemas .k de nakui modules pasan a .ncl.
  4. card.k eliminado (es REFERENCE ONLY documentado).

feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica

Pivote arquitectónico decidido en charla: Brahman maneja varios formatos legítimos de "Card" (cada formato vive en su crate origen y conserva su shape público), y un único brazo los lee, completa desde templates si vienen simplificados, y los proyecta a UNA sola estructura interna canónica que consumen UI runtime / storage / DHT / wire. Agregar un formato nuevo = agregar un reader, sin tocar consumers.

V1 en este commit: estructura canónica + readers para los 3 formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado para próximo commit).

Crate nuevo crates/core/brahman-cards/:

13 tests integration:

13/13 verdes. Workspace build verde tras agregar el crate al members[] del workspace Cargo.toml.

Lo que NO hace V1 (explícito):

Pendientes para próximos commits (orden):

  1. Reader Nickel + template merge.
  2. Migrar consumers (nakui-ui consume brahman_cards::load_card).
  3. Yahweh refactor: lift del MetaUi runtime a crates/modules/ui_engine/ (esperando hasta que el brazo + canónico estén estables).
  4. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts; los 3 schemas .k de nakui modules pasan a .ncl.
  5. 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:

5 tests nuevos:

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

Comportamiento esperado:

Pendientes restantes:

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):

Cambios en nakui-ui:

Tests nuevos:

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:

Pendientes restantes:

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:

2 tests nuevos:

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

Trade-offs:

Pendientes restantes:

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:

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:

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:

5 tests nuevos (reemplazan al obsoleto parse_field_entity_ref_returns_string):

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:

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:

5 tests nuevos:

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

Trade-offs y notas:

Pendientes restantes:

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:

5 tests nuevos del helper:

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:

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:

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:

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:

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

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:

Pendientes futuros (orden de prioridad):

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:

Runtime nakui-ui:

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

Tests:

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

Pendientes futuros:

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:

Runtime nakui-ui:

Tests: 2 nuevos:

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

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:

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:

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:

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

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:

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):

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:

Wire en Cargo:

Limitaciones que siguen abiertas (próximos iters):

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):

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

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

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:

Activación:

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

Limitaciones conocidas (próximas iteraciones):

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:

UI:

Wire en workspace:

Tests: 7 unitarios en tests mod del bin:

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).

feat(minga-core): α-hashing per-language para Python, TypeScript, JavaScript, Go

Cierra el último pendiente fundamentado del CHANGELOG. Cada lenguaje soportado por minga tiene ahora su propio profile α-equivalente — dos versiones del mismo programa que difieren sólo en nombres de variables ligadas producen el mismo hash, no importa el lenguaje. Refactorings tipo "rename variable" no inflan el storage del repo en ningún dialecto.

Refactor de alpha.rs (639 LOC) a módulo alpha/:

Cobertura per-language:

Python (def, lambda, for, comprehensions, with):

ECMAScript (TS + JS):

Go:

API:

Tests: 26 nuevos en tests/alpha_polyglot.rs:

141 tests verdes en minga-core (115 antes; +26 polyglot). Refactor sin regresión: 36 α-Rust tests siguen pasando.

Pendientes que quedan en Minga (orden de prioridad):

feat(minga-core): cierre del α-hashing de Rust — if let, while let, let-else, or-pattern, let-chains

Cierra los 5 pendientes documentados en alpha.rs. El hash α-equivalente ahora es estable bajo renombre de TODOS los binders de Rust, no sólo los del MVP (parámetros, let, for, match arms).

Pendientes cerrados:

Helper nuevo: feed_let_condition para que el pattern del let_condition pase por feed_pattern (que distingue binders vs constructors). Sin esto, los identifiers del pattern se hasheaban como variables libres y Some(x) ≠ Some(y) aún teniendo el mismo significado.

Tests: 6 nuevos en tests/alpha_invariants.rs:

36 tests α verdes (eran 30). 115 tests totales en minga-core.

Lo que esto significa: el hash α-equivalente de Rust en minga es completo — cubre todos los constructos del lenguaje que introducen bindings. Dos versiones del mismo programa que difieren sólo en nombres de variables (incluyendo en if let, while let, or-pattern, etc.) producen el mismo hash y por tanto la misma identidad CAS. Refactorings del tipo "rename variable" no inflan el storage del repo.

Pendientes futuros:

feat(minga): multi-lenguaje en parser — Python, TypeScript, JavaScript, Go

Minga deja de ser Rust-only. Cualquiera de los cinco dialectos (Rust + 4 nuevos) se ingresa al CAS por su AST normalizado, hashea estructuralmente, sincroniza por DHT como cualquier nodo. La auto-detección por extensión hace que minga ingest archivo.py o .ts o .go "simplemente funcione".

API nueva en minga_core::parse:

Wire en minga-cli:

Notas sobre hashing:

Deps nuevas (workspace + minga-core):

Tests:

Pendientes futuros del changelog:

feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico

Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar la keypair libp2p de un nodo cambiaba su peer_id, lo que invalidaba todas las allowlists/denylists remotas que lo referenciaban. Imposible rotar sin coordinar con todos los pares.

Solución: separar identity master (Ed25519 persistente forever, identifica al nodo como entidad lógica) de session libp2p (Ed25519 efímera, rotable). El master firma certs de session con expiración. La política de admisión se evalúa contra el master_peer_id del cert — el session peer_id puede cambiar libremente sin tocar las allowlists.

API nueva en brahman_handshake::identity:

Wire:

Server (do_handshake):

Canonicalización del payload firmado:

[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms]

SESSION_CERT_VERSION = 1 documenta el esquema; cualquier cambio fuerza bump (clientes viejos no validan certs nuevos).

Sobre el swarm-level deny:

Tests: 8 unit en identity::tests:

Plus 1 E2E definitivo en network_libp2p.rs: identity_cert_allows_session_rotation_without_policy_change.

40 tests verdes en brahman-handshake + brahman-net (24 unit incluyendo identity + 7 handshake + 3 discovery + 6 libp2p incluyendo rotation E2E). Ningún regreso.

Wire en Arje queda como follow-up: ente-zero hoy es server-only y no necesita identity (su keypair libp2p ya es estable vía keypair_store). Cuando algún módulo de Arje haga conexiones salientes con cert, se cargará la identity master separada de la session vía nueva env BRAHMAN_IDENTITY_PATH. La API ya está lista.

feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p

Optimización de seguridad: la denylist ya no espera al handshake brahman para rechazar — ahora se proyecta al block_list behaviour del swarm libp2p. Conexiones desde peers baneados son rechazadas antes del Noise handshake, ahorrando el round-trip TCP+Noise por cada intento denegado.

Wire de bajo nivel (brahman-net):

Wire en la política (brahman_handshake::peer_policy):

Wire en Arje (ente-zero):

Tests: 1 nuevo E2E en network_libp2p.rs: swarm_level_deny_blocks_before_noise. A configura policy con deny de un peer + attach_to_net. Cliente baneado intenta connect_libp2p; en lugar del HandshakeError::Unauthorized que recibíamos antes (que requería completar Noise primero), ahora falla con error de transporte/stream (o timeout, según timing) — el dial nunca completa porque el swarm rechaza la conexión.

5 tests verdes en network_libp2p.rs (roundtrip, mismatched signing, allowlist, denylist handshake-level, denylist swarm-level). 31 tests totales en brahman-handshake + brahman-net. Sin regresión en ente-zero.

Trade-offs:

Pendientes futuros del changelog:

feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers

Consolida PeerAllowlist + nueva PeerDenylist en un único PeerPolicy con allow + deny + hot reload vía notify. Cubre los dos pendientes documentados en el commit anterior y simplifica la API hacia un sólo punto de entrada.

API consolidada en brahman_handshake::peer_policy:

Orden de evaluación (deny-first):

  1. Si peer ∈ denylist → DeniedByDenylist.
  2. Si hay allowlist y peer ∉ allowlist → NotInAllowlist.
  3. Resto → Admit.

Esto significa que deny gana sobre allow: un peer en ambas listas es rechazado. Diseño explícito para que la denylist sea la primitiva de "kill switch" — agregar un peer al deny lo banea inmediatamente sin importar dónde más esté listado.

Watcher: vigila el directorio padre del archivo, no el archivo mismo. Razón: editores típicos hacen rename-and-replace (escriben a tmp y rename al destino), lo que rompe el watch del archivo pero no el del dir. Filtra eventos por path al procesar.

Wire en server:

Wire en Arje (ente-zero):

Activación end-to-end con todas las capas activas:

BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \
BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \
ente-zero
# El operador puede editar deny.txt en caliente y la nueva regla
# entra en efecto en ~250ms sin restart del Init.

Tests: 10 unit en peer_policy::tests:

Plus 1 E2E nuevo en network_libp2p.rs: libp2p_handshake_denylist_blocks_listed_peer — A configura policy = PeerPolicy::from_sets(None, [banned_peer]). Cliente con keypair baneada es rechazado; cliente con keypair distinta pasa el handshake.

30 tests verdes en brahman-handshake (16 unit + 7 handshake + 3 discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin regresión en ente-zero.

Lo que cierra esta entrega:

Pendientes futuros del changelog:

feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p

Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora cualquier peer con keypair Ed25519 válida pasaba el handshake remoto; con allowlist activa, sólo los peers explícitamente listados. Aplica únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED del kernel, que es autenticación de proceso local, no de red.

API nueva en brahman_handshake::peer_allowlist:

Wire en el server:

Wire en Arje (ente-zero):

Ejemplo de archivo de allowlist:

# Peers permitidos en la malla brahman de prod-eu-1
# Generados con: ente-zero (peer_id loggeado al arrancar)
12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz
12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux  # operador 2

Activación end-to-end:

BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\
ente-zero

Tests:

25 tests verdes en brahman-handshake (12 unit + 7 handshake legacy

Pendiente futuro:

feat(brahman-net+handshake): stop_providing automático en cleanup de sesión

Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión con outputs cerraba (Farewell, EOF, error), el record que la anunciaba en el DHT seguía vivo hasta su TTL natural (~24h en kad default). Consumers remotos podían descubrir un peer "vivo" que ya no servía nada.

Cambios:

Tests: nuevo E2E dht_discovery_withdraws_on_session_cleanup:

  1. A registra Card con flow.output = monad-list:json.
  2. B descubre a A vía find_remote_providers — confirma before.contains(&a_peer).
  3. Cliente local de A hace farewell → cleanup → withdraw_outputs.
  4. Espera a que la sesión salga del registro (señal de cleanup completado) + 100ms para que el swarm procese el Command.
  5. Nueva query desde B: after NO debe contener a_peer.

3 tests verdes en network_discovery.rs (positivo, negativo, withdraw). 18 tests totales en handshake + net.

Pendiente futuro: retracción cross-peer en kad (requeriría extensión del protocolo libp2p, no soportada hoy). Aceptable: simétrico al modelo de propagación eventual del DHT.

feat(ente-zero): wire de Arje con brahman-net (red P2P opcional + identidad persistente)

Cierra el último pendiente del plan de red: Arje ahora puede arrancar opcionalmente con BrahmanNet configurado, persistir su identidad libp2p entre reboots, y participar en la malla brahman como nodo público. Sin breaking changes: usuarios actuales (sin env vars) siguen viendo el comportamiento Unix-only de antes.

Activación por env vars:

Comportamiento al activarse:

  1. keypair_store::load_or_generate(path) carga la keypair de disco o genera+persiste una nueva (32 bytes raw, permisos 0o600, atomic rename). Reboots conservan el peer_id.
  2. BrahmanNet::with_keypair(kp) arma el swarm con esa identidad.
  3. net.listen(multiaddr) espera dirección resuelta y la loggea.
  4. BRAHMAN_BOOTSTRAP_PEERS (si set) → dial a cada multiaddr.
  5. El handshake server se levanta con ServerConfig.net = Some(net), que activa announce_outputs automático en el DHT por cada Card con outputs.
  6. Además del Unix accept loop (existing), se monta un libp2p accept loop sobre el mismo Server compartido. Sesiones locales y remotas conviven en las mismas tablas (sessions, push_table, broker, last_matches).

Refactor del Unix accept loop: antes consumía el server vía server.run().await; ahora usa Arc<Server>::accept_one().await en loop para coexistir con el libp2p accept loop sin moverse el server.

Degradación grácil en cada paso: si la keypair no carga, si el multiaddr es inválido, si el listen falla, si el bootstrap dial revienta — loggeamos y seguimos en modo Unix-only. La doctrina de PID 1 ("ningún subsistema opcional rompe el bucle primordial") se mantiene.

Tests: 4 unit en keypair_store:

Ente-zero compila clean. Ningún test del workspace regresa.

Lo que esto desbloquea hoy:

Pendientes futuros:

feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p

Cuarto y último paso del plan "el encuentro entre Entes no se restringe a local". Cierra la falla de seguridad que dejaba la red P2P abierta: hasta ahora, un atacante que pudiera dial-ar al multiaddr del Init podía registrar cualquier Card con cualquier label/flow. Fase 3 cierra esto exigiendo que el Hello vía libp2p venga firmado con la misma keypair Ed25519 que produce el peer_id autenticado por Noise.

Modelo:

Wire (brahman_handshake::messages):

Nuevo módulo brahman_handshake::signature:

Server:

Client:

BrahmanNet:

Tests:

Lo que esto cierra:

Pendientes (futuro, no de hoy):

feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type

Tercer paso del plan "el encuentro entre Entes no se restringe a local". Cuando un Init local acepta una sesión cuya Card declara outputs, anuncia al DHT (Kademlia, vía brahman-net) que él provee esos flow types. Cualquier nodo conectado al mismo DHT puede consultar y obtener la lista de PeerIds que sirven el flow.

API nueva en brahman_handshake::network:

Wire en el server:

Canonicalización del TypeRef:

Tests: 2 nuevos en tests/network_discovery.rs:

Lo que esto desbloquea:

Lo que queda para Fase 3 (trust):

feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p

Segundo paso del plan "el encuentro entre Entes no se restringe a local". El protocolo brahman (Hello / HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard length-prefixed) ahora también viaja sobre streams libp2p de la malla brahman-net — el mismo Init acepta sesiones por Unix socket Y por libp2p indistintamente, y un consumer remoto puede dial-ar al multiaddr y completar handshake.

Cambios:

Deps: brahman-handshake gana brahman-net, futures, tokio-util. brahman-net re-exporta Multiaddr, PeerId, Stream, StreamProtocol, Protocol, OpenStreamError para que callers no necesiten dep directa a libp2p.

Tests:

Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo flow type, broker query local + remoto).

feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)

Primer paso del plan "el encuentro entre Entes no se restringe a local". El swarm libp2p que vivía dentro de minga-p2p::network (282 LOC) sale a una crate compartida brahman-net para que cualquier protocolo de la familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.

Diseño:

Migración:

Aclaración semántica anclada por el usuario: Arje es el init (PID 1, runtime, ente-zero/kernel/soma); Brahman es el encuentro entre Entes (handshake/broker/card/sidecar/ahora también net). El nombre de la crate refleja que la malla pertenece al encuentro, no al runtime — Arje puede usar la malla, Minga usa la malla, cualquier futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.

Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior verificado idéntico — sólo se movió código, ningún cambio funcional. Próximo: Fase 1 (handshake brahman sobre libp2p stream).

refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path

Cierra el único debt estructural detectado en el audit de independencia: nouser-explorer ya no arrastra nouser-core (que aportaba notify/walkdir/sled/blake3 al grafo de compilación de una UI que sólo habla JSON contra un socket).

Cambios:

Audit estructural confirmó que el resto del ecosistema ya respeta el principio: todos los yahweh-* viewers, minga-cli, minga-core, nouser-card, nouser-nous, los providers nouser-nous-{mock,real} y nakui-core corren standalone con soft-fail hacia infra brahman cuando está ausente. Brahman es "pegamento opcional, no chasis obligatorio" — y ahora el grafo de Cargo lo enforcea, no sólo la convención.

Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes. El cliente movido se ejercita end-to-end por los 3 tests integración de engine_socket (importa ahora nouser_card::query::client).

feat(explorer+daemon): discovery dinámico vía broker + query socket

La UI deja de hardcodear el socket admin: ahora descubre al daemon nouser vía MatchEvent::Available del broker brahman y le consulta sus Mónadas directo, sin pasar por brahman-admin. Cierra el "explorer encuentra al daemon de forma totalmente dinámica" del meta-plan.

Pipeline end-to-end:

Wire types nuevos en nouser_card::query:

Listener en nouser_core::engine_socket:

Refactor explorer:

Trade-offs aceptados:

Tests: 10 (nouser-card, +3 query) + 27 (nouser-core, +3 engine_socket)

feat(nous-real): cache de embeddings + write-through al CAS de arje

Cierra el ciclo de la crítica del usuario: "Si un archivo no ha cambiado su hash en el CAS, Nouser ni siquiera debería pedirle al LLM que re-genere el embedding". El modelo real (fastembed-allMiniLML6V2-384d, ~1-50ms por archivo) era invocado ciegamente en cada re-cluster del watcher. Ahora se cachea por sha256(bytes-vistos) + model_id.

Pipeline en handle_file:

  1. Lee primeros 8 KiB (igual que antes).
  2. file_sha = ente_cas::sha256_of(buf) — hash de los bytes que el modelo realmente verá (no del archivo completo). Garantiza que un archivo creciendo más allá de la ventana sin tocar la cabeza siga sirviendo cache hits.
  3. Cache lookup: HIT → respuesta en ~µs.
  4. MISS → ente_cas::store(&buf) (write-through al CAS de arje, no-fatal si falla) → backend.embed_one(text) → cache.put(...).

Backend de cache: sled local en $XDG_CACHE_HOME/brahman/nouser-nous-real-embed-cache.sled. Tree versionado embed_cache_v1; el MODEL_ID viaja en la key, así que cambiar de modelo invalida el cache implícitamente. Override por env NOUSER_NOUS_REAL_CACHE.

Encoding compacto: cada Vec<f32> se serializa como bytes little-endian (4B por f32, sin overhead). Para el modelo default (384-d) son 1.5 KiB por entry. Decode tolera bytes corruptos (longitud no-múltiplo de 4 → None, no panic).

Por qué sled y no ente-cas directo: el CAS de arje es flat sha256-keyed; la cache necesita un mapeo (file_sha, model_id) → embedding, no expresable como entry CAS. El write-through a CAS queda como registro consultable + futura GC.

API:

Mock NO se modifica — su embedding pseudo-32d es metadata-hashing puro, sin costo. Cachearlo sería overhead.

Tests: 5 unitarios (roundtrip_returns_same_vector, miss_returns_none, different_models_do_not_collide, different_content_different_keys, corrupted_value_returns_none). Verdes con --features embeddings; stub mode (sin feature) sigue compilando sin tocar cache.

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:

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.

feat(card): Card::new(label) — alternativa segura a Default::default()

Cierra la trap documentada de Card::default() que devuelve id = Ulid::nil(). Usar Card::default() "viva" colisionaba con cualquier otra Card default-construida bajo el mismo id 00000000…. La fix no es romper Default (sigue siendo determinista, requerido por callers que lo usan como template para deserialización), sino agregar un constructor explícito:

let card = Card { kind: CardKind::Data, payload: Payload::Embedded(json), ..Card::new("mi-modulo.algo") };

Card::new(label) asigna id = Ulid::new() (único) + label provisto, dejando el resto en defaults seguros (Virtual / OneShot / Ente). Pensado para usarse en struct-literals con override parcial, igual sintaxis que el patrón viejo pero sin la trap.

Refactor de call sites:

Default se mantiene tal cual con docstring expandida que advierte explícitamente sobre el uso "vivo" y apunta a Card::new. Tests existentes y el patrón nouser_card::MonadManifest::to_brahman_card (que asigna el id estable de la Mónada, no uno fresco) NO se modifican — Default sigue siendo correcto cuando el caller sobreescribe id explícitamente.

Tests: 3 unitarios nuevos en brahman-card (new_assigns_real_ulid_and_label, new_yields_distinct_ids_per_call, default_keeps_nil_id_for_struct_update_pattern). 15 tests verdes (era 12).

feat(sidecar): API reusable de discovery vía broker

Promueve el patrón ad-hoc discover_producer_socket (que vivía inline en nouser attract --remote) a un módulo público brahman_sidecar::discovery. Cualquier consumer puede ahora preguntar al broker "¿quién provee este TypeRef?" con dos llamadas:

// Construir un consumer Card mínimo (Ente, Oneshot, Virtual) let card = brahman_sidecar::build_consumer_card( "mi-cli", "embed-result", // flow.input.name "json", // TypeRef::Primitive { name } );

// Bloqueante (CLIs, std-thread loops): let socket: PathBuf = brahman_sidecar::await_provider_blocking( card, Duration::from_secs(3), )?; // O async (módulos con runtime tokio propio): let socket = brahman_sidecar::await_provider(card, timeout).await?;

API:

Refactor en nouser daemon:

Próximo consumer natural: nouser-explorer. Hoy renderea StatusSnapshot vía socket admin (introspección pura). El día que quiera interactuar con un Ente — p. ej., disparar un re-embed desde la UI — usa este helper para resolver el socket del provider sin hardcodear paths.

Nota sobre identidad: este commit fuerza Ulid::new() para los consumer Cards generados, evitando la trampa documentada del Card::default() que devuelve Ulid::nil(). La fijación global de Default queda como cleanup separado (requiere auditar que ningún caller dependa del determinismo de nil).

Tests: 4 unitarios nuevos en discovery::tests (id no-nil, id único por llamada, formateo de TypeRef::Wit, fallback sin input). Workspace verde.

feat(nouser+sidecar): watcher con debounce + re-publish al broker

Cierra las dos limitaciones del watcher previo: ya no spamea N veces por una sola edición, y el broker ve los cambios estructurales en lugar de quedarse con manifests congelados al arranque.

$ nouser daemon /tmp/x & $ touch /tmp/x/src/a.rs /tmp/x/src/b.rs /tmp/x/src/c.rs

daemon log (un solo batch, no 9 reacciones):

[watcher] ⚙ batch: 6 path(s) coalescidos → re-scan [watcher] ✦ x/src nace (3 miembros, lens=Code) [watcher] ⌃ delta: 1 nuevas, 0 refrescadas, 0 cerradas — 3 sesiones vivas

Mecánica del debounce (150ms):

Mecánica del re-publish:

Trade-off aceptado: re-scan global por batch (no incremental). Es O(N archivos) por evento y para árboles típicos (<10k) cae en <100ms. Optimizar a re-cluster parcial cuando duela.

Tests: workspace completo verde.

feat(nouser): notify watcher — el sistema reacciona en tiempo real

El daemon ahora monta un notify::recommended_watcher recursivo sobre el directorio. Cada Create/Modify de archivo regular dispara: embedding del archivo, filtro por centroid_model, ranking contra centroides existentes, log con marker 🧲 / · según supere el umbral de atracción.

$ nouser daemon /tmp/x &

en otra terminal:

$ vim /tmp/x/src/nuevo.rs

daemon log:

[watcher] 🧲 /tmp/x/src/nuevo.rs → x/src (0.7470)

$ echo "edit" >> /tmp/x/docs/n1.md [watcher] 🧲 /tmp/x/docs/n1.md → x/docs (0.8169)

Mecánica:

Limitación conocida: notify emite múltiples eventos por una sola edición (Create + Modify, etc.). Sin debounce, el watcher reporta varias veces. Aceptable para demo; production conviene debounce ~100ms por path.

Tests: 7 (card) + 24 (core) verdes, 0 errores, 0 warnings.

feat(nouser): hidratación del daemon vía sled + path_hint

El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene Mónadas previas con centroid_model válido, las publica instantáneo y el re-scan reusa sus IDs vía path_hint.

Schema:

Algoritmo (cluster):

Daemon (cmd_daemon):

  1. Open sled si NOUSER_DB_PATH existe.
  2. Publica las Mónadas previas con centroid_model válido (las inválidas se descartan con log explícito).
  3. Re-scan + by_directory_hydrated(prior=&db).
  4. Sólo spawnea sidecars para Mónadas con id que NO estaba en la hidratación inicial. Los path_hints existentes preservan identidad, evitando duplicados en el broker.
  5. Persiste el set actualizado.

Validación end-to-end:

$ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core

arranque 1: DB vacía

re-scan 102 archivos → 5 mónadas
1 ente + 5 mónadas vivas (5 nuevas vs hidratación)

$ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core

arranque 2: DB poblada

hidratadas 5 mónadas previas en O(1)
re-scan 102 archivos → 5 mónadas
1 ente + 5 mónadas vivas (0 nuevas vs hidratación)

Costo del arranque 2: ~0.06s user CPU. Antes (sin hidratación) era re-scan + cluster + spawn x N — segundos enteros para árboles grandes.

Tests: 7 (card) + 24 (core) verdes.

feat(nouser): centroid_model — versionado de embeddings

Protege contra el bug silencioso de mezclar centroides de modelos distintos (mock 32-d vs real 384-d), que daba scores sin sentido.

Resultado operativo: cambiar de mock a real (vía BRAHMAN_BROKER_CONTEXT=prod) hace que attract filtre las Mónadas viejas con cero score en lugar de fingir que las puede comparar.

2026-05-08

chore: profile.dev slim — target/ ~50% más liviano

Cambios en [profile.dev] raíz para que builds futuras no desborden disco. Decisiones:

Resultado: binarios ~3× más livianos. ente-zero 125→47 MB; mock-nous ~50→22 MB.

feat(nouser): dynamic binding — consumer descubre el provider vía broker

Cierra el bucle prometido por priority_contexts: el cliente ya no hardcodea el socket del provider de embeddings. En su lugar:

  1. Si NOUSER_NOUS_SOCKET está set, lo usa directo (atajo explícito).
  2. Si no, abre brahman_handshake::client::Client al brahman-init, anuncia un consumer Card mínimo con flow.input = embed-result:json, espera 3s por el primer MatchEvent::Available, y usa el producer_service_socket que viaja en el evento.

Esto activa el swap automático mock↔real:

Validación end-to-end:

$ ente-zero & nouser-nous-mock & $ # Sin NOUSER_NOUS_SOCKET: $ nouser attract --remote crates/core archivo.rs embed: remote 🧲 0.9058 ente-brain/src ... (mock log confirma "embed_file path=...")

Cambios:

Bug que se descubrió en el camino: la "flakiness" reportada de cargo test --workspace era disco lleno (24 GB en target/), no condición de carrera. Con cargo clean + profile slim, todos los tests pasan deterministas.

feat(nouser): yahweh widget — nouser-explorer panel GPUI

Bin GPUI standalone que consulta brahman-admin cada 2s y renderea todas las sesiones del Init como cards. Cierra el círculo visual del ecosistema brahman.

Uso:

$ ente-zero & nouser daemon crates/core & $ cargo run -p nouser-explorer

ventana muestra ~6 cards en vivo, refrescando cada 2s.

cargo check --workspace: 0 errores, 0 warnings.

feat(nouser): persistencia sled write-through del MonadDb

MonadDb ahora soporta backend dual:

Diseño:

Bin nouser: nueva env var NOUSER_DB_PATH. Si está set, persiste en esa ruta; si no, in-memory:

$ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core scan: 102 archivos en crates/core, 5 mónadas $ ls /tmp/monads.sled blobs conf $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core

segunda corrida re-escribe la DB con el nuevo scan

Tests nuevos en db.rs:

24 tests en nouser-core (era 22, +2).

feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime

Antes: cada spawn(card) creaba un thread + tokio runtime propio. Para módulos que publican muchas sesiones (nouser daemon con 50+ Mónadas) eso es 50 threads + 50 runtimes. Ahora: un thread + un runtime tokio current_thread que hostea N tasks de sidecar.

API nueva (aditiva, no rompe spawn/spawn_with_handle):

let pool = SidecarPool::new()?; pool.spawn(card1); pool.spawn(card2); pool.spawn_conscious(card_wit, wit); pool.spawn_with_config(SidecarConfig::new(c).with_wit(w)); // pool drop = todas las sesiones cierran.

run_client se hace pública para que el pool pueda enqueuar tasks externos al runtime con handle.spawn(run_client(config)).

nouser daemon migrado al pool. Verificación con ps -L:

$ ps -L -p $(pidof nouser) LWP CMD 28817 nouser # main thread 28819 brahman-sidecar # pool thread (todas las sesiones)

Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar cuántas Mónadas se publiquen.

feat: Crossreferencia — Card.references como grafo del fractal

Las Cards ahora declaran sus relaciones con otras Cards. El Engine posee Mónadas; las Mónadas declaran que son poseídas por el Engine. La UI puede cruzar el grafo sin discovery especial.

Validación end-to-end:

$ ente-zero & nouser daemon crates/core $ brahman-status Sessions (6): [ente] ... brahman.nouser_engine [data] ... brahman-handshake/src ref OwnedBy → brahman.nouser_engine (01K...) summary: 6 archivos... [data] ... ente-brain/src ref OwnedBy → brahman.nouser_engine (01K...) ...

feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten

Cierra el ciclo del swap automático de Nous (mock↔real):

Validación end-to-end:

$ ente-zero & nouser-nous-mock & nouser-nous-real & $ ls /run/user/1001/nouser-nous-*.sock nouser-nous-mock.sock nouser-nous-real.sock

$ brahman-status Sessions (2): [ente] ... nouser.nous_real socket: /run/user/1001/nouser-nous-real.sock in embed-request: Primitive { name: "json" } out embed-result: Primitive { name: "json" } [ente] ... nouser.nous_mock socket: /run/user/1001/nouser-nous-mock.sock in embed-request, out embed-result

Pendientes para futuro (no críticos):

refactor(nouser): labels de Mónada con 2 componentes del path

Resuelve la fricción visual de monorepos donde múltiples Mónadas se llamaban "src". Nueva función label_from_path toma los últimos hasta 2 componentes normales del path y los une con /.

$ nouser scan crates/core [01K..] brahman-admin/src card=5 [01K..] brahman-handshake/src card=6 [01K..] ente-brain/src card=11 [01K..] ente-kernel/src card=4 ...

Tests añadidos: label_from_root_only_one_component, label_from_deep_path_takes_last_two. Tests existentes actualizados con los nuevos labels.

feat(nouser): Phase D-2 — proveedor Nous real (LLM) detrás de feature flag

Cierra el ciclo del módulo Nous: existe un proveedor que produce embeddings reales con un modelo LLM, mientras que cargo build sin features sigue siendo liviano (no descarga ni compila ML deps).

Crate nuevo:

Validación end-to-end con modelo real:

$ cargo build -p nouser-nous-real --features embeddings # ~50s $ ente-zero & nouser-nous-real & $ # probe vía python al socket Unix: $ echo '{"kind":"embed_text","payload":{"text":"hello brahman"}}'
| python3 -c "..." | head model: real-fastembed-allMiniLML6V2-384d elapsed_ms: 8 embed_dim: 384 first 5 values: [0.0034, -0.0036, 0.0078, -0.0218, -0.0162]

Tradeoff conocido: las dimensiones del mock (32-d) y real (384-d) son incompatibles. Cambiar de proveedor invalida los centroides cacheados de Mónadas. Documentar como "limpiar DB al cambiar proveedor".

Workspace state:

Pendientes para D-3 / futuro:

feat(nouser): Phase D — proveedor Nous mock + cliente remoto

Cierra el patrón "Nous como módulo aparte intercambiable": el contrato del proveedor de embeddings vive en su crate, el mock determinístico implementa ese contrato sirviéndolo por Unix socket, y nouser-core sabe consumirlo remotamente. El switch entre mock y real (futuro) se hará vía priority_contexts en el broker.

Crates nuevos:

Cambios:

Validación end-to-end (un solo terminal, varios procesos):

$ ente-zero & $ nouser-nous-mock & $ NOUSER_MIN_FILES=5 nouser daemon crates/core & $ brahman-status

Sessions (7): [ente] nouser.nous_mock flows: embed-request, embed-result [ente] brahman.nouser_engine [data] src summary: 6 archivos en crates/core/brahman-handshake/src [data] graph summary: 7 archivos en crates/core/ente-zero/src/graph ...

$ nouser attract --remote crates/core <archivo.rs> embed: remote 🧲 0.9058 src ...

Mock log: "embed_file path=crates/modules/nouser/core/src/embed.rs"

Bug encontrado y corregido en el camino:

Tests acumulados: 75 (card 12 +1 nuevo, broker 15, handshake 9, card-wit 4, admin 0, nouser-card 7, nouser-core 20, nouser-nous 2). cargo check --workspace: 0 errores, 0 warnings.

Próximo natural: Phase D-2 — real-nous con un modelo ONNX/Llama de text-embedding. La infraestructura ya está lista: declara la misma Card con priority_contexts.prod = { priority_offset: +1 } y el swap es transparente para el consumer.

feat(nouser): Phase C — pseudo-embeddings + atracción por centroide

El "imán semántico" matemático del diseño Kairos, sin LLM. Cada archivo se proyecta a un vector 32-d derivado de sus metadatos; cada Mónada calcula su centroide; archivos nuevos se asignan por cosine similarity contra los centroides existentes.

Cambios:

Validación end-to-end:

$ nouser attract crates/core crates/modules/nouser/core/src/embed.rs ranking de atracción (cosine similarity): 🧲 0.9058 [01K..] src (11 archivos en crates/core/ente-brain/src) 0.8984 [01K..] src (6 archivos en crates/core/brahman-handshake/src) 0.8918 [01K..] src (5 archivos en crates/core/ente-zero/src) ...

$ nouser attract crates/core crates/modules/nouser/core/Cargo.toml ranking: 0.3427 [01K..] graph (7 archivos en crates/core/ente-zero/src/graph) ... (mejor score 0.3427 < umbral 0.7000 — el archivo no se 'pega')

Tests: 20 en nouser-core (era 13, +7 de embed). Total acumulado: 73 (card 11, broker 15, handshake codec+tr 2 + integ 7, card-wit 4, admin 0, nouser-card 7, nouser-core 20, ente-card 0). cargo check --workspace: 0 errores, 0 warnings.

Próximo: Phase D — nouser-nous, módulo aparte para LLM real. Mock-nous determinista (basado en estos pseudo-embeddings) en BRAHMAN_BROKER_CONTEXT=test; real-nous en prod. El switch lo hace el broker via priority_contexts sin tocar nada más.

feat(nouser): Phase B-2 — daemon que publica Mónadas al Init

Cierra la unificación: el nouser daemon se sidecarea como Ente y publica cada Mónada como su propia sesión Data. Un solo brahman-status muestra procesos y datos en la misma lista, exactamente como buscaba el diseño.

Cambios:

Validación end-to-end:

$ ente-zero & $ NOUSER_MIN_FILES=5 nouser daemon crates/core & $ brahman-status

Sessions (6): [ente] ... brahman.nouser_engine lifecycle=Daemon [data] ... src summary: 5 archivos en crates/core/brahman-admin/src members: 5 (dispersion=0.00) lens hint: code [data] ... src summary: 11 archivos en crates/core/ente-brain/src ... [data] ... graph summary: 7 archivos en crates/core/ente-zero/src/graph

El protocolo de presentación es uno solo: la Card. La función — anunciar identidad, exponer metadata, ser descubierto — es idéntica para procesos vivos y agrupaciones de datos. La UI lo ve como una lista uniforme.

Costo conocido: cada Mónada consume un thread + tokio runtime current_thread (legacy del sidecar API). Para muchas Mónadas (>50) conviene consolidar en un único runtime con N tasks. Defer a Phase B-3.

Pendientes propuestos:

cargo check --workspace: 0 errores, 0 warnings.

feat: Phase B-1 — unificación ontológica de Cards (Ente ↔ Data)

La Card es el protocolo de presentación del ecosistema, no sólo de los procesos. Una Mónada Nouser y un Ente Brahman son ambos "entidades que se presentan"; el consumidor (UI, broker, admin) discrimina por kind cuando importa, pero todos hablan el mismo idioma.

Cambios:

Resultado: la UI (yahweh, brahman-status, futuro explorer) ve una sola lista uniforme. No tiene que saber si está mirando un proceso o un cúmulo de datos — sólo lee el Card y se adapta por kind.

Tests acumulados: 59 (card 11, broker 15, handshake codec+transport 2 + integ 7, card-wit 4, admin 0, nouser-card 7, nouser-core 13). cargo check --workspace: 0 errores, 0 warnings.

Próximo: Phase B-2 — bin nouser daemon <dir> que sidecarea cada Mónada como una sesión brahman, publicándola al broker. Brahman-status las verá junto a los entes.

feat(nouser): Phase A — mecanismo determinista de Mónadas

Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como "imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los casos sin tocar IA — sólo metadatos y heurísticas.

Crates nuevos:

Demo end-to-end:

$ nouser scan crates scan: 255 archivos en crates, 19 mónadas (min_files=3) [01KR4C13] src card=12 ent=0.00 lens=Code keywords: rs [01KR4C13] tests card=14 ent=0.00 lens=Code keywords: rs [01KR4C13] fixtures card=5 ent=0.00 lens=Grid keywords: rhai ...

$ nouser show crates 01KR4C Monad 01KR4C1370DVF6NMTW6SECNXAF label: src summary: 4 archivos en crates/modules/nouser/core/src (ext: rs) cardinality: 4 entropy: 0.0000 lens: Code members (4): 4132 bytes crates/modules/nouser/core/src/db.rs ...

Pendientes para próximas fases (anotados, no urgentes):

Workspace: 0 errores, 0 warnings. Tests acumulados: 58 (card 11, broker 15, handshake codec+transport 2 + integ 7, card-wit 4, admin 0, nouser-card 6, nouser-core 13).

feat(broker): priority contexts — biases per-contexto operativo

feat(card): WireCard + extensions — forward-compat sin romper postcard

9420eae chore: limpia warnings dead-code en arje (commit del usuario)

feat(sidecar): WIT al sidecar — módulos conscientes vivos

feat(core): brahman-card-wit — extractor opcional de contratos WIT

7b589b8 chore: agrega CHANGELOG.md retroactivo

8a83a26 feat(handshake): notificación push de matches

70a7a0d feat: segundo módulo (nakui) + admin API + brahman-status

595f68e feat(yahweh-shell): primer módulo brahman vivo

df9d10c feat(ente-zero): enchufa el handshake server al Init real

07d77a3 feat(handshake): integra el broker con el ciclo de sesiones

5091106 feat(core): brahman-broker — matching híbrido

814390f feat(core): brahman-handshake — protocolo runtime

ed0e973 refactor(arje): migra ente-card a re-export de brahman-card

0feba74 feat(core): brahman-card — Tarjeta canónica híbrida

4d50bfc chore: absorbe nakui (ERP matemático) en modules/nakui

53dbdf0 chore: monorepo inicial con arje + minga + yahweh absorbidos