tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Sutichaykuna

⎇ main docs/changelog/protocol.md Markdown · 1612 siq'ikuna · 77.1 KB Ñawpa willay ch'uya

feat(mirada): mirada-greeter — greeter de login del escritorio carmen bb21c28eb · sergio ·

1# Changelog — protocol/
2
3Contratos canónicos + routing entre módulos. Antes: `core/brahman-*` + `shared/brahman-*`.
4
5### feat(brahman-auth): SessionTicket — el tiquet greeter→compositor
6
7`brahman_auth::SessionTicket { user: UserInfo, session: String }`: lo
8que el greeter le entrega al compositor tras un login exitoso.
9`to_line`/`from_line` lo serializan a una **línea única** (campos por
10tabulador, prefijo versionado `MIRADA-SESSION-TICKET-v1`) — el greeter
11la imprime a stdout y el compositor escanea sus líneas buscando el
12prefijo. 5 tests de round-trip y rechazo de líneas malformadas.
13
14### feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock
15
16Crate nuevo `crates/protocol/brahman-auth`: la base del DM/greeter de
17carmen (mirada). Contrato `Authenticator` agnóstico del backend:
18`authenticate(usuario, secreto) -> UserInfo`, donde `UserInfo` lleva
19`uid/gid/home/shell` — lo que el compositor necesita para arrancar la
20sesión.
21
22- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`,
23  por defecto `carmen`), el mismo subsistema de `login`/`sudo`. Un handle
24  PAM nuevo por intento; `authenticate()` cubre credenciales + estado de
25  la cuenta. `map_pam_error` traduce los `PamReturnCode` a la taxonomía
26  gruesa de `AuthError`.
27- **`MockAuthenticator`** — credenciales fijas en memoria, para tests y
28  para iterar el greeter en cajas sin PAM.
29- `AuthError` deliberadamente grueso: `BadCredentials` (reintentar) vs
30  `AccountUnavailable` (cuenta vetada); usuario inexistente y contraseña
31  errada dan el **mismo** error (no filtra existencia de cuentas).
32- `resolve_user` vía `getpwnam` (nix). `UserInfo::synthetic` para dev.
33- `data/carmen` — archivo de servicio PAM. Ejemplo `auth-probe` para
34  verificar PAM en una máquina real.
35- 11 tests; el camino PAM real se ejercita (falla limpio sin servicio).
36
37### feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers
38Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o
39future-me retomando el repo) puede levantar el escenario completo
40con un comando.
41
42Crate nuevo `crates/apps/brahman-demo/` con 3 binarios:
43- **`brahman-demo-broker`**: standalone `Server::bind` con un Broker
44  configurado, escucha en el socket default. Reemplaza a
45  `ente-zero` para fines de demo (ente-zero pesa kernel surface +
46  child subreaper + bus + brain + audit; el demo no lo necesita).
47- **`brahman-demo-producer`**: registra una Card con `flow.output[demo-stream:json]`
48  y queda pingueando.
49- **`brahman-demo-consumer`**: registra una Card con `flow.input[demo-feed:json]`
50  (mismo type → matchea con el producer) y queda escuchando
51  `MatchEvent`s.
52
53Variables de entorno respetadas en los 3: `BRAHMAN_INIT_SOCKET`,
54`BRAHMAN_BROKER_CONTEXT` (sólo broker), `BRAHMAN_DEMO_LABEL/FLOW/TYPE`,
55`RUST_LOG`.
56
57Script nuevo `scripts/bootstrap-demo.sh`:
58- Modos: `all` (default — broker + producer + consumer + 4 explorers),
59  `broker` (sin GUIs, sólo backend), `only` (sólo broker, sin
60  producer/consumer ni GUIs).
61- Cleanup-safe: trap `EXIT INT TERM` mata todos los procesos
62  spawneados (con SIGTERM grace + SIGKILL fallback) y borra el socket.
63- Espera activa hasta 5s a que el socket aparezca antes de spawnear
64  los siguientes (evita ENOENT en el handshake).
65- Logs separados por proceso bajo `$BRAHMAN_DEMO_LOG_DIR` (default
66  `/tmp/brahman-demo`). Re-invocaciones limpian los logs viejos.
67- Re-build automático opcional (comentado por default — asume
68  `cargo build --workspace` ya hecho).
69
70Smoke verificado end-to-end (sin DISPLAY, sólo backend):
71- Broker arranca, bind del socket OK.
72- Consumer conecta, asigna session.
73- Producer conecta, asigna session.
74- Consumer recibe `MatchEvent { Available, demo-feed ← demo-stream,
75  via: Exact, pinned: false }` automáticamente — el broker computó
76  el match y lo pusheó por el push channel.
77
78Stack tests: brahman-demo (0 unit), workspace verde.
79
80### feat(brahman-handshake): ListMatches endpoint + timeline en broker-explorer
81Iter 21. Cierra el loop de observabilidad iniciado en iter 20: ahora
82se ven no sólo las sesiones registradas sino también qué matches
83consumer↔producer está computando el broker en cada momento, y la
84historia de cómo cambian.
85
86`brahman-handshake/messages.rs`:
87- **`Frame::ListMatches(ListMatches{session})`**: pedido (mismo
88  patrón de validación session-id).
89- **`Frame::MatchList(MatchList{matches: Vec<brahman_broker::Match>})`**:
90  respuesta. Cada `Match` ya es serializable y lleva `consumer`,
91  `consumer_label`, `producer`, `producer_label`, `ty`, `via`, `pinned`.
92
93`brahman-handshake/server.rs`:
94- `run_post_handshake` ahora pasa también `broker_for_match: Option<&SharedBroker>`
95  al `handle_inbound_frame`.
96- Si el server tiene broker configurado, `ListMatches` responde con
97  `broker.all_matches()`. Si no (server sin broker), responde
98  `MatchList { matches: vec![] }` — refleja "no hay matching activo",
99  no es un error.
100
101`brahman-handshake/client.rs`: `Client::list_matches()` análogo a
102`list_sessions()`, drena `MatchEvent`s intermedios al buffer.
103
104`brahman-sidecar/discovery.rs`: `list_matches` y `list_matches_blocking`
105con la misma forma de Card observer minimalista.
106
107`brahman-broker-explorer`:
108- Cada poll-tick ahora pide TANTO `list_sessions` COMO `list_matches`.
109- `Explorer.last_match_keys: HashSet<MatchKey>` mantiene el estado
110  del último snapshot. La key es `(consumer.session, consumer.flow,
111  producer.session, producer.flow)`.
112- `Explorer.timeline: VecDeque<TimelineEntry>` con cap `TIMELINE_CAP=50`.
113- Función pura `diff_matches(last_keys, list) -> (entries, new_keys)`:
114  emite `Available` para keys nuevas y `Lost` para keys desaparecidas.
115  Primer tick (last_keys vacío) marca todo como Available — cubre
116  el boot sin que la UI quede vacía.
117- Render: `stat_card` "Timeline de matches" con count + 20 entries
118  formateadas como `HH:MM:SS {+/-} consumer.flow ← producer.flow [via]`.
119  Más reciente arriba.
120
121Tests broker-explorer: 5 totales.
122- `diff_matches_first_snapshot_marks_everything_available`
123- `diff_matches_emits_lost_when_match_disappears`
124- `diff_matches_no_change_emits_nothing`
125- `pending_is_default_state_at_boot` (existente)
126- `poll_and_probe_constants_are_sane` (existente)
127
128Decisión: timeline polled (cada `POLL_INTERVAL=5s`), no push.
129Razón: los `MatchEvent` push del broker son consumer-céntricos
130(cada session sólo ve sus propios matches). Para "system-wide
131timeline" haría falta una API broker-level "subscribe a todos" —
132mucho más scope. Polling cada 5s es suficiente para observabilidad.
133
134### feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer
135Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier
136módulo conectado puede preguntar al broker la lista de sesiones
137activas y mostrar labels + flows in/out por cada una.
138
139`brahman-handshake/messages.rs`:
140- **`Frame::ListSessions(ListSessions { session })`**: request del
141  cliente (server valida que `session` coincida con la sesión vigente,
142  mismo patrón que Ping/Farewell).
143- **`Frame::SessionList(SessionList { entries })`**: respuesta.
144  Cada `SessionEntry` lleva: `session`, `label`, `schema_version`,
145  `outputs` (nombres de flow.output), `inputs` (nombres de
146  flow.input), `conscious` (`true` si la Card vino con WIT).
147
148`brahman-handshake/server.rs`:
149- `run_post_handshake` ahora pasa `SessionRegistry` a
150  `handle_inbound_frame` (necesario para consultar el snapshot de
151  sesiones en respuesta a `ListSessions`).
152- Helper `build_session_list(sessions)` que toma el snapshot bajo
153  el lock, lo proyecta a `SessionList`, y suelta el lock antes de
154  escribir el frame al wire.
155- Validación `session_id` mismatched → `HandshakeError::Unauthorized`.
156
157`brahman-handshake/client.rs`:
158- `Client::list_sessions()` async: envía el request, drena
159  `MatchEvent`s intermedios al `pending_events` buffer (mismo patrón
160  que `ping`), retorna el `SessionList`.
161
162`brahman-sidecar/discovery.rs`:
163- `pub async fn list_sessions(observer_label)` y
164  `pub fn list_sessions_blocking(observer_label)`: arman una Card
165  observer mínima (sin flow.input/output), conectan, piden la lista,
166  Farewell. Para CLIs y módulos std-thread.
167
168`brahman-broker-explorer`:
169- Cada poll-tick (cuando el broker está UP*) ahora también pide
170  `list_sessions_blocking` y guarda el snapshot en `Explorer.sessions`.
171- Render extiende el body con un `stat_card` "Sesiones activas" que
172  muestra el count + lista ordenada por `session` (Ulid temporal),
173  cada item: `label · in:[flows] out:[flows]  (wit?)`.
174
175Tests:
176- `list_sessions_returns_currently_registered`: levanta server con
177  broker, conecta 3 clientes (alpha, beta, observer), observer pide
178  `list_sessions`, verifica los 3 labels presentes y que la entry
179  del observer reporte `conscious=false` y el `schema_version`
180  esperado.
181- Stack: handshake suite (24 tests), sidecar (3+8 unit + integ),
182  broker-explorer (4 tests). Todo verde.
183
184### feat(brahman-broker-explorer): nueva app probe del broker brahman
185Iter 14. Cierra otro frente: visibilidad del broker brahman (el
186broker handshake que matchea Cards consumer/producer). Hasta ahora
187no había forma de "ver" si el broker estaba up sin invocar otro
188binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3
189estados claros.
190
191Crate nuevo `crates/apps/brahman-broker-explorer/`:
192- **Deps**: `brahman-handshake`, `brahman-sidecar` + el stack
193  yahweh themed (theme + 3 widgets). Consume el mismo
194  `await_provider_blocking` que usa `nouser-explorer`.
195- **`ProbeState` enum** con 4 variants:
196  - `Pending` (estado inicial al boot, antes del primer probe).
197  - `Down { reason }` — connect failed, broker no escucha.
198  - `UpNoProvider { flow }` — broker reachable + sin productor
199    para el flow probado dentro del timeout.
200  - `UpWithProvider { flow, producer_socket }` — broker reachable
201    + matcheó algo, devuelve el socket del provider.
202- **Polling loop** en `cx.spawn` cada 5s; el probe (que es
203  bloqueante porque internamente usa tokio runtime) se ejecuta en
204  `cx.background_executor().spawn(...)` para no congelar el main
205  thread del UI.
206- **Configuración via env**:
207  - `BRAHMAN_INIT_SOCKET` — path del broker (default resuelto por
208    `brahman_handshake::transport`).
209  - `BRAHMAN_BROKER_PROBE_FLOW` — flow del Card observer
210    (default `broker-health`).
211  - `BRAHMAN_BROKER_PROBE_TYPE` — type name (default `ping`).
212- **UI**: header con probe info + theme switcher; banner permanente
213  (Error/Warning/Success/none según estado) debajo del header;
214  stat card con accent color por estado y descripción.
215- 2 tests sanity (default state es Pending; constants coherentes:
216  PROBE_TIMEOUT < POLL_INTERVAL >= 2s).
217
218Smoke run del binario verificado: bootstrap completo OK, panic
219esperado en open_window por falta de display.
220
221Beneficio operativo:
222- Si tenés un broker corriendo en `~/.local/share/brahman/init.sock`,
223  el explorer lo detecta + reporta estado verde con su socket.
224- Si no hay broker, banner rojo + msg claro indicando el path
225  probado.
226- Si hay broker pero ningún Card produce el flow probado, banner
227  amber — útil para distinguir "broker down" de "broker up,
228  no productor del tipo X".
229- Apuntando el flow/type via env, podés monitor productores
230  específicos: ej. `BRAHMAN_BROKER_PROBE_FLOW=monad-list
231  BRAHMAN_BROKER_PROBE_TYPE=json` para ver si nouser está sirviendo.
232
233Apps GUI integradas al stack themed: **5** (nakui-ui, nakui-explorer,
234nouser-explorer, minga-explorer, brahman-broker-explorer).
235
236Limitaciones documentadas:
237- El observer registra una Card temporal en cada probe (cada 5s).
238  Eso ensucia un poco las estadísticas del broker (Cards
239  registradas/desregistradas). No impacta funcionalidad pero
240  inflama el log si el broker tiene observability habilitada.
241- No muestra la **lista global** de Cards registradas en el broker
242  — el protocolo handshake actual no expone esa API. Para eso
243  habría que agregar un endpoint `ListSessions` al broker server.
244- No mantiene un buffer de MatchEvents. Cada probe es independiente.
245  Para timeline de matches, hace falta mantener el Client vivo
246  entre probes — scope futuro.
247
248### feat(brahman-cards): templates Nickel canónicos para cada body kind
249Materializa el patrón "import + override" del brazo: hasta ahora
250`BRAHMAN_CARDS_TEMPLATES_DIR` existía como mecanismo pero el repo
251no shippeaba ningún template. Ahora hay 3 templates basic (uno
252por body kind del CardBody) bajo
253`crates/core/brahman-cards/templates/`:
254
255- **`ente_basic.ncl`** — Card runtime mínima: `payload="Virtual"`,
256  `supervision="OneShot"`, `schema_version=1`. Override típico:
257  `id` + `label`.
258- **`monad_basic.ncl`** — agrupación semántica de archivos
259  (Mónada Nouser): metadata vacía, `dominant_lens="grid"` (lowercase
260  por convención serde rename_all). Override típico: `id`, `label`,
261  `members`, `cardinality`.
262- **`ui_module_basic.ncl`** — descriptor UI con `entities=[]`,
263  `menu=[]`, `views={}`. Override típico: `id`, `label` y los
264  3 payloads.
265
266Cada field override-able marcada `| default` (sin eso Nickel
267rebota merge de strings/numbers no-iguales).
268
269API nueva en `lib.rs`:
270- **`pub fn canonical_templates_dir() -> PathBuf`**: devuelve el
271  directorio de templates del crate (resuelto via
272  `CARGO_MANIFEST_DIR`). Útil para apuntar el env
273  `BRAHMAN_CARDS_TEMPLATES_DIR` en runtime/tests sin hardcoding
274  del path.
275- Doc explica que para distribución del binary standalone (cuando
276  emerja), incluir templates como recursos via `include_dir!` o
277  instalar el directorio junto al ejecutable.
278
2795 tests E2E (`tests/templates.rs`) que cubren:
280- `ente_basic` import + override `id`+`label` → Card body Ente
281  con `payload=Virtual` (default preserved).
282- `monad_basic` import + override `id`+`label`+`cardinality` →
283  Card body Monad con members=[] y summary="" (defaults).
284- `ui_module_basic` import + override de `id`+`label`+menu+views
285  → Card body UiModule con entities=[] (default).
286- Sanity: import sin override → defaults `"TEMPLATE_ID"` /
287  `"TEMPLATE_LABEL"` pasan al wrapper sin error.
288- Sanity: el path de `canonical_templates_dir()` apunta a un
289  directorio existente con los 3 archivos esperados.
290
291Helper de test `with_canonical_templates(F)` setea/restaura el
292env localmente; cada test single-thread-safe.
293
294Tests suite brahman-cards: 26 → **31** verdes (+5). El resto del
295workspace intacto.
296
297Beneficio operativo:
298- Un usuario que quiera declarar un Card nuevo puede empezar con
299  un override de 2 líneas (`id` + `label`) en lugar de copiar el
300  shape full desde cero.
301- Templates auto-documentan la convención `| default` para que
302  copiar uno y agregar fields propios "just works" en merge.
303- El brazo sigue siendo agnostic — los templates son sólo
304  archivos `.ncl` resueltos via el import resolver Nickel; nada
305  hardcoded en código Rust.
306
307Limitaciones:
308- No hay templates "ricos" tipo `crud_basic.ncl` que parametricen
309  por entity name. Nickel no expone funciones-templates de la
310  forma típica de templating engines; lo más cercano sería un
311  template con un field `entity_name | String` y references
312  internas via `me.entity_name`. Cuando aparezca el caso de uso
313  real (e.g., un módulo donde el patrón list+form es repetitivo),
314  se diseña el template paramétrico.
315- `canonical_templates_dir()` resuelve via `CARGO_MANIFEST_DIR` —
316  funciona en `cargo` (test/run/build) pero no para un binary
317  instalado fuera del workspace. Para release distribution la API
318  necesitará un fallback (resources embedded o convención de
319  install path).
320
321### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
322Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
323los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
324los mismos readers JSON estándar. Un `.ncl` puede producir
325cualquier `CardBody` siempre que su shape sea reconocida. Los
326templates funcionan con los `import` + `&` merge nativos de
327Nickel — el brazo no inventa una mecánica paralela.
328
329Cambios:
330- **Dep `nickel-lang = "2.0.0"`** (interfaz estable, no
331  `nickel-lang-core` que es internal/inestable). Compila clean
332  pero suma ~1 min al build cold del crate.
333- **Nuevo módulo `nickel_eval.rs`** con `eval_nickel_file(path) ->
334  Result<Value, NickelEvalError>`. Errores tipados:
335  `Io`, `Eval`, `Export`, `JsonReparse` — el mensaje de Nickel se
336  formatea como texto plano (sin ANSI) para que sea legible en
337  logs y toasts.
338- **`load_card_with` añade `"ncl"`**: lee archivo → eval Nickel →
339  exporta a JSON → parsea de vuelta a Value → dispatch a los
340  readers JSON. Pipeline simétrico a `"json"`.
341- **`CardLoadError::Nickel(NickelEvalError)`**: el error de
342  Nickel se propaga limpio al error público del brazo.
343- **Resolución de imports**:
344  - El parent dir del input se agrega como import path → `import
345    "./template.ncl"` resuelve sin config.
346  - El env `BRAHMAN_CARDS_TEMPLATES_DIR` (constante exportada
347    `BRAHMAN_CARDS_TEMPLATES_ENV`) agrega un registry global →
348    `import "ui_module_minimal.ncl"` desde cualquier ubicación.
349  - No hay magic resolución por kind. El autor del Card decide
350    qué template importa.
351
352**Convención obligatoria de templates** (documentada en
353`nickel_eval.rs`): las fields que el usuario va a sobrescribir
354deben marcarse `| default` (o `| optional`). Sin ese marker
355Nickel rechaza el merge de strings/numbers no-iguales con la
356misma prioridad. Patrón canónico:
357
358```nickel
359# template ui_module_basic.ncl
360{
361  id | String | default = "TEMPLATE_ID",
362  label | String | default = "TEMPLATE_LABEL",
363  ...
364}
365
366# uso concreto
367let base = import "ui_module_basic.ncl" in
368base & { id = "my_id", label = "Mi Label" }
369```
370
3719 tests nuevos en `tests/nickel.rs`:
372- `eval_nickel_file_returns_value_for_valid_input` — happy path.
373- `eval_nickel_file_surfaces_evaluation_error` — variant `Eval`
374  con path + message.
375- `load_card_dispatches_ncl_to_ui_module_variant` — pipeline
376  e2e a UiModule.
377- `load_card_dispatches_ncl_to_ente_variant` — pipeline e2e a
378  Ente.
379- `template_merge_overrides_id_and_label_only` — el caso del
380  user: template + override de id+label, resto del template
381  intacto.
382- `template_resolves_via_env_registry` — uso del env como
383  registry global.
384- `load_card_wraps_nickel_error_in_card_load_error` — wrap
385  limpio del error.
386- `nickel_contract_violation_caught_at_eval_time` — value-add
387  concreto: `id | String = 42` falla en eval, no en deserialize
388  ni aguas abajo.
389- `ncl_evaluating_to_unknown_shape_returns_no_matching_reader`
390  — sanity de coherencia con dispatcher JSON.
391
39222 tests en total en `brahman-cards` (13 JSON V1 + 9 Nickel V2).
393Workspace build verde tras la dep nueva.
394
395**Lo que NO hace V2** (sigue pendiente):
396- No migra consumers — `nakui-ui` sigue cargando con
397  `nakui_ui_schema::load_modules_from_dir`. La migración a
398  `brahman_cards::load_card` queda para después.
399- No define un set canonical de templates en el repo (algo
400  como `templates/ente_basic.ncl`, `templates/ui_module_minimal.ncl`).
401  Eso emerge cuando aparezcan los primeros casos de uso reales
402  donde dos cards comparten estructura.
403- No hace cross-validation entre template + override (ej:
404  detectar que un override saca un campo required del template).
405  Nickel ya lo hace via contracts si el template tiene un schema.
406- No expone una API streaming (load N cards en paralelo). El
407  use case actual es one-shot al boot.
408
409**Pendientes para próximos commits** (orden):
4101. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
4112. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`.
4123. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
413   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
4144. card.k eliminado (es REFERENCE ONLY documentado).
415
416### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
417**Pivote arquitectónico** decidido en charla: Brahman maneja varios
418formatos legítimos de "Card" (cada formato vive en su crate origen y
419conserva su shape público), y un **único brazo** los lee, completa
420desde templates si vienen simplificados, y los proyecta a UNA sola
421estructura interna canónica que consumen UI runtime / storage / DHT /
422wire. Agregar un formato nuevo = agregar un reader, sin tocar
423consumers.
424
425**V1 en este commit**: estructura canónica + readers para los 3
426formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado
427para próximo commit).
428
429Crate nuevo `crates/core/brahman-cards/`:
430- **`Card { id, schema_version, lineage, label, extensions, body }`**:
431  wrapper común con identidad legible + extensiones forward-compat.
432  `id` como String (no `Ulid`) porque cada body variant usa un tipo
433  de id distinto (Ulid para Ente/Monad, slug human-friendly para
434  UiModule). PartialEq omitido del derive porque `MonadManifest` y
435  `nakui_ui_schema::Module` no lo implementan en sus crates origen.
436- **`CardBody`** enum etiquetado `kind`:
437  - `Ente(brahman_card::Card)` — entidad runtime con
438    payload/soma/supervision.
439  - `Monad(nouser_card::MonadManifest)` — agrupación semántica de
440    archivos.
441  - `UiModule(nakui_ui_schema::Module)` — descriptor de UI con
442    entities/views/menu.
443  - Convención: agregar variant nuevo + reader; los consumers que
444    sólo manejen algunos hacen `match { Ente(..) => ..., _ => skip }`.
445- **`trait CardReader`**: `name()` + `can_read(&Value) -> bool` +
446  `read(Value) -> Result<Card>`. El dispatcher prueba en orden y
447  delega al primero que matchee.
448- **3 readers concretos** (en `readers.rs`):
449  - `EnteJsonReader` — heurística: `payload` Y `supervision`
450    presentes simultáneamente.
451  - `MonadJsonReader` — heurística: `members` Y `cardinality`.
452  - `UiModuleJsonReader` — heurística: `entities` Y `views` Y
453    `menu`. El más específico, va primero en `default_readers()`.
454- **Entry points**:
455  - `load_card(path)` — abre archivo, dispatcha por extensión, dentro
456    de JSON prueba los readers default.
457  - `load_card_with(path, readers)` — variante con set custom para
458    apps que quieren restringir formatos.
459- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`,
460  `NoMatchingReader`, `ReaderFailed { reader, message }`,
461  `UnsupportedExtension { ext, supported }`.
462
46313 tests integration:
464- 3 detection tests (cada reader matchea sólo su shape, rechaza los
465  otros 2 + non-object).
466- 3 dispatch+projection tests (cada formato JSON cargado produce el
467  variant esperado con campos del wrapper bien derivados).
468- 2 negative cases (NoMatchingReader, non-object input).
469- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples
470  readers — defiende el contrato de orden documentado).
471- 1 e2e desde disco con `load_card_with`.
472- 1 unsupported extension.
473- 1 custom reader set (restringir a sólo Ente).
474- 1 documented invariant (extensions vacío en V1; si cambia, este
475  test se rompe como signal).
476
47713/13 verdes. Workspace build verde tras agregar el crate al
478`members[]` del workspace Cargo.toml.
479
480**Lo que NO hace V1** (explícito):
481- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda
482  aislada para no inflar este commit.
483- No define templates — los templates Nickel se diseñan junto al
484  reader Nickel (necesitan `merge` nativo de Nickel para fusionar
485  override + base).
486- No migra consumers. `nakui-ui` sigue cargando `module.json` con
487  `nakui_ui_schema::load_modules_from_dir` directo. La migración a
488  `brahman_cards::load_card` viene cuando V1 + Nickel + templates
489  estén estables.
490- No mueve los `extensions` del input a `Card.extensions` — los crates
491  origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`).
492  Documentado como decisión consciente.
493
494**Pendientes para próximos commits** (orden):
4951. Reader Nickel + template merge.
4962. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
4973. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`
498   (esperando hasta que el brazo + canónico estén estables).
4994. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
500   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
5015. card.k eliminado (es REFERENCE ONLY documentado).
502
503### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico
504Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar
505la keypair libp2p de un nodo cambiaba su `peer_id`, lo que
506invalidaba todas las allowlists/denylists remotas que lo
507referenciaban. Imposible rotar sin coordinar con todos los pares.
508
509Solución: separar **identity master** (Ed25519 persistente forever,
510identifica al nodo como entidad lógica) de **session libp2p**
511(Ed25519 efímera, rotable). El master firma certs de session con
512expiración. La política de admisión se evalúa contra el
513`master_peer_id` del cert — el session peer_id puede cambiar
514libremente sin tocar las allowlists.
515
516API nueva en `brahman_handshake::identity`:
517- `Identity::from_keypair(master)` — wrapper sobre la master kp.
518- `Identity::master_peer_id()` — el peer_id estable del nodo.
519- `Identity::issue_session_cert(session_kp, ttl) -> SessionCert` —
520  firma un cert que vincula session_pubkey + expires_at_ms.
521- `SessionCert::verify()` — chequea versión, firma criptográfica,
522  no expiración. Devuelve `(master_peer_id, session_peer_id)`.
523- `SessionCert::verify_against_session(expected_session_pk)` — verify
524  + exige que el cert vincule esa session pubkey (previene reuso de
525  certs ajenos con keypairs distintas).
526- `CertError` tipado: `UnknownVersion`, `DecodeMaster`,
527  `DecodeSession`, `InvalidSignature`, `Expired`, `SessionMismatch`,
528  `Sign`.
529- `DEFAULT_SESSION_TTL = 24h`.
530
531Wire:
532- `Hello.identity_cert: Option<SessionCert>` agregado (default None,
533  back-compat).
534- `Client::connect_with_stream_signed_with_cert(stream, card, wit,
535  session_kp, cert)` — variante que adjunta el cert.
536- `network::connect_libp2p_with_cert(net, peer, card, wit,
537  session_kp, cert)` — paralelo a `connect_libp2p`.
538
539Server (`do_handshake`):
540- Nuevo paso ANTES del policy gate: si `Hello.identity_cert.is_some()`,
541  se verifica con `verify_against_session(&hello.signature.public_key)`.
542  El `logical_peer` que se evalúa contra la policy es el
543  `master_peer_id` derivado, NO el session peer_id.
544- Sin cert (path Fase 3): `logical_peer = expected_peer` (compat).
545- Si el cert es inválido (firma rota, expirado, session mismatch),
546  rechazo con `Unauthorized` antes de evaluar policy.
547- Migración gradual: clientes sin cert siguen funcionando contra
548  servers con policy basada en session peer_ids.
549
550Canonicalización del payload firmado:
551```
552[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms]
553```
554`SESSION_CERT_VERSION = 1` documenta el esquema; cualquier cambio
555fuerza bump (clientes viejos no validan certs nuevos).
556
557Sobre el swarm-level deny:
558- El `block_list` del swarm sigue operando con session peer_ids
559  (Noise sólo conoce eso). Si la operatoria lista master_peer_ids
560  en deny, el handshake-level gate los para; el swarm-level no.
561  El operador elige granularity: listar masters = robust a
562  rotaciones; listar sessions = rechazo más temprano.
563
564Tests: 8 unit en `identity::tests`:
565- `issue_and_verify_cert` — roundtrip básico, peer_ids derivados.
566- `verify_against_session_admits_matching` y
567  `_rejects_mismatch` — el cert vincula 1 sola session pubkey.
568- `cert_with_zero_ttl_is_expired` — expiración chequeada con tiempo
569  real.
570- `tampered_signature_rejected` y `tampered_expires_at_rejected` —
571  cualquier mutación del cert post-firma falla.
572- `unknown_version_rejected` — schema versionado defensivamente.
573- `rotated_session_with_same_master_yields_same_master_peer_id` —
574  la propiedad fundamental: rotar session NO cambia master_peer_id.
575
576Plus 1 E2E definitivo en `network_libp2p.rs`:
577`identity_cert_allows_session_rotation_without_policy_change`.
578- A configura `policy = allowlist[B.master_peer_id]` (master, no
579  session).
580- B se conecta con session1 + cert(master, session1) → admitido.
581  Sesión registrada, farewell limpio.
582- B "rota": genera session2 ≠ session1, mismo master, emite cert2.
583- B se conecta con session2 + cert2 → admitido también, **sin que
584  A toque su allowlist**.
585- Sanity: una session sin cert (cuyo session_peer NO está en allow)
586  es rechazada.
587
58840 tests verdes en brahman-handshake + brahman-net (24 unit
589incluyendo identity + 7 handshake + 3 discovery + 6 libp2p
590incluyendo rotation E2E). Ningún regreso.
591
592Wire en Arje queda como follow-up: ente-zero hoy es server-only y
593no necesita identity (su keypair libp2p ya es estable vía
594keypair_store). Cuando algún módulo de Arje haga conexiones
595salientes con cert, se cargará la identity master separada de la
596session vía nueva env `BRAHMAN_IDENTITY_PATH`. La API ya está
597lista.
598
599### feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p
600Optimización de seguridad: la denylist ya no espera al handshake
601brahman para rechazar — ahora se proyecta al `block_list` behaviour
602del swarm libp2p. Conexiones desde peers baneados son rechazadas
603**antes del Noise handshake**, ahorrando el round-trip TCP+Noise
604por cada intento denegado.
605
606Wire de bajo nivel (`brahman-net`):
607- Nuevo behaviour `block_list: allow_block_list::Behaviour<BlockedPeers>`
608  añadido al `BrahmanBehaviour` derivado. Vive junto a `stream`,
609  `kad`, `identify`. Default vacío al construir.
610- Nuevos comandos `BlockPeer(PeerId)` y `UnblockPeer(PeerId)` en el
611  enum interno + handlers que llaman
612  `swarm.behaviour_mut().block_list.{block_peer,unblock_peer}`.
613- API pública: `BrahmanNet::block_peer(peer)` y
614  `BrahmanNet::unblock_peer(peer)`. Idempotentes.
615- Dep nueva: `libp2p-allow-block-list = "0.6"` (sub-crate, no es
616  feature de `libp2p` en 0.56).
617
618Wire en la política (`brahman_handshake::peer_policy`):
619- `PeerPolicy` gana campo opcional `net: Arc<RwLock<Option<Arc<BrahmanNet>>>>`.
620  Default `None` para preservar callers existentes.
621- Nuevo método `attach_to_net(net: Arc<BrahmanNet>)`:
622  - Sincronización inicial: itera la deny actual y llama
623    `net.block_peer(p)` por cada uno.
624  - Guarda el net para diff-sync en cada `reload`.
625- `reload()` extendido: snapshot de `prev_deny` ANTES de mutar el
626  inner. Tras la mutación, llama `sync_deny_to_swarm(prev, new)`
627  que aplica `block_peer` por cada added y `unblock_peer` por cada
628  removed.
629- Atomicidad preservada: si un archivo falla al parsear, el sync
630  no ocurre y la versión anterior persiste tanto en la policy
631  como en el block_list del swarm.
632
633Wire en Arje (`ente-zero`):
634- Tras setup_brahman_net + setup_brahman_policy, si AMBOS están
635  presentes se llama `policy.attach_to_net(net.clone())` con un log
636  informativo. Sin policy o sin net, no hay attach (modo abierto
637  o solo gate-level deny).
638
639Tests: 1 nuevo E2E en `network_libp2p.rs`:
640`swarm_level_deny_blocks_before_noise`. A configura policy con
641deny de un peer + attach_to_net. Cliente baneado intenta
642`connect_libp2p`; en lugar del `HandshakeError::Unauthorized` que
643recibíamos antes (que requería completar Noise primero), ahora
644falla con error de transporte/stream (o timeout, según timing) —
645el dial nunca completa porque el swarm rechaza la conexión.
646
6475 tests verdes en `network_libp2p.rs` (roundtrip, mismatched signing,
648allowlist, denylist handshake-level, denylist swarm-level). 31 tests
649totales en brahman-handshake + brahman-net. Sin regresión en
650ente-zero.
651
652Trade-offs:
653- **Más eficiente** contra DoS: un atacante que prueba miles de
654  peer_ids no consume CPU del Noise handshake.
655- **Misma fuente de verdad**: la denylist sigue viviendo en
656  `PeerPolicy` (un solo archivo, hot-reloadable). El swarm es un
657  cache derivado que se actualiza vía diff. No hay drift posible —
658  cada reload re-sincroniza atómicamente.
659- **El handshake-level gate sigue activo** como segunda línea: si
660  por alguna razón un peer baneado pasa el block_list (race entre
661  reload y nueva conexión, o bug del crate), el handshake brahman
662  igual lo rechaza con `Unauthorized`. Defensa en profundidad.
663
664Pendientes futuros del changelog:
665- Rotación de keypair sin perder peer_id (multi-key identity).
666
667### feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers
668Consolida `PeerAllowlist` + nueva `PeerDenylist` en un único
669`PeerPolicy` con allow + deny + hot reload vía `notify`. Cubre los
670dos pendientes documentados en el commit anterior y simplifica la
671API hacia un sólo punto de entrada.
672
673API consolidada en `brahman_handshake::peer_policy`:
674- `PeerPolicy::open()` — todo permitido (default).
675- `PeerPolicy::from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>)`
676  — política inline para tests.
677- `PeerPolicy::from_files(allow_path?, deny_path?)` — carga ambos
678  archivos opcionales.
679- `PeerPolicy::evaluate(peer) -> Decision` — `Admit |
680  DeniedByDenylist | NotInAllowlist`. Decision lleva su `reason()`
681  para logging consistente.
682- `PeerPolicy::reload()` — recarga atómica desde los paths
683  asociados. **Si un archivo falla, conserva la versión anterior**
684  (un typo no debe tirar al Init en modo inseguro).
685- `PeerPolicy::spawn_watcher() -> JoinHandle` — vigila los
686  archivos vía `notify`, debounce 250ms (coalesce de los varios
687  eventos típicos de un save), recarga atómica al detectar cambio.
688
689Orden de evaluación (deny-first):
6901. Si `peer ∈ denylist` → `DeniedByDenylist`.
6912. Si hay allowlist y `peer ∉ allowlist` → `NotInAllowlist`.
6923. Resto → `Admit`.
693
694Esto significa que **deny gana sobre allow**: un peer en ambas listas
695es rechazado. Diseño explícito para que la denylist sea la primitiva
696de "kill switch" — agregar un peer al deny lo banea inmediatamente
697sin importar dónde más esté listado.
698
699Watcher: vigila el **directorio padre** del archivo, no el archivo
700mismo. Razón: editores típicos hacen rename-and-replace (escriben
701a tmp y rename al destino), lo que rompe el watch del archivo pero
702no el del dir. Filtra eventos por path al procesar.
703
704Wire en server:
705- `ServerConfig.allowlist` → `ServerConfig.policy: Option<PeerPolicy>`
706  (breaking rename, scope local al monorepo). Gate en `do_handshake`
707  llama `policy.evaluate(&peer)` y usa `decision.reason()` para el
708  mensaje de error tipado.
709
710Wire en Arje (`ente-zero`):
711- Nueva env `BRAHMAN_PEER_DENYLIST` complementa
712  `BRAHMAN_PEER_ALLOWLIST`. Cualquiera (o ambas) activa la política.
713- `setup_brahman_policy()` carga + arranca watcher. Devuelve
714  `(policy, JoinHandle)`; el handle se guarda en main para que el
715  thread no se aborte.
716- Failure modes degradan a "modo abierto" (sin política) con log,
717  preservando la doctrina PID 1.
718
719Activación end-to-end con todas las capas activas:
720```sh
721BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \
722BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \
723BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \
724ente-zero
725# El operador puede editar deny.txt en caliente y la nueva regla
726# entra en efecto en ~250ms sin restart del Init.
727```
728
729Tests: 10 unit en `peer_policy::tests`:
730- `open_admits_anyone`, `allow_only_admits_listed`,
731  `deny_overrides_open`, `deny_overrides_allow` (deny-first
732  semantics).
733- `from_files_with_both_lists`, `from_files_only_deny`,
734  `invalid_file_rejected_at_load`.
735- `reload_picks_up_changes` — manualmente recarga y verifica.
736- `reload_failure_preserves_previous_state` — invariante de
737  seguridad: archivo roto NO baja la política activa.
738- `watcher_reloads_on_file_change` — E2E del watcher con notify
739  real: muta archivo, espera < debounce + margen, verifica que
740  la política refleja el cambio sin haber llamado reload manualmente.
741
742Plus 1 E2E nuevo en `network_libp2p.rs`:
743`libp2p_handshake_denylist_blocks_listed_peer` — A configura
744`policy = PeerPolicy::from_sets(None, [banned_peer])`. Cliente
745con keypair baneada es rechazado; cliente con keypair distinta
746pasa el handshake.
747
74830 tests verdes en brahman-handshake (16 unit + 7 handshake + 3
749discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin
750regresión en ente-zero.
751
752Lo que cierra esta entrega:
753- Política completa de admisión: open / allow-only / deny-only /
754  allow+deny.
755- Hot reload sin restart del Init — el operador puede banear/admitir
756  peers en caliente editando archivos.
757- Atomicidad: la recarga es del paquete `(allow, deny)` completo, no
758  de cada lista por separado. No hay momento donde una lista esté
759  vieja y la otra nueva.
760- Resiliencia: errores de parseo NO bajan la política activa.
761
762Pendientes futuros del changelog:
763- Aplicar la política a nivel de swarm vía `libp2p_allow_block_list::
764  Behaviour` (rechazar ANTES del Noise handshake, ahorrar el
765  round-trip TCP+Noise por intento denegado).
766- Rotación de keypair sin perder peer_id (multi-key identity).
767
768### feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p
769Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora
770cualquier peer con keypair Ed25519 válida pasaba el handshake remoto;
771con allowlist activa, sólo los peers explícitamente listados. Aplica
772únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED
773del kernel, que es autenticación de proceso local, no de red.
774
775API nueva en `brahman_handshake::peer_allowlist`:
776- `PeerAllowlist::from_iter([peer_id, ...])` para tests/inline.
777- `PeerAllowlist::from_file(path)` parsea texto plano: un PeerId
778  base58 por línea, `#` para comentarios (línea entera o inline),
779  líneas vacías ignoradas. Errores de parseo incluyen número de
780  línea para debug rápido.
781- `is_allowed(peer)`, `len()`, `is_empty()`, `iter()`.
782- `AllowlistError { Io, InvalidPeerId }`.
783
784Wire en el server:
785- `ServerConfig.allowlist: Option<PeerAllowlist>`. `None` = modo
786  abierto (compat con todo lo anterior). `Some` = sólo los listados.
787- Gate en `do_handshake` ANTES de la verificación de firma — la
788  comparación O(log n) en BTreeSet es más barata que crypto, así
789  que rechazamos peers inválidos antes de gastar ciclos. Se devuelve
790  `HandshakeError::Unauthorized("peer X no está en la allowlist")`.
791
792Wire en Arje (`ente-zero`):
793- Nueva env var `BRAHMAN_PEER_ALLOWLIST` apuntando a un archivo.
794- `setup_brahman_allowlist()` carga al startup; degrada a `None`
795  (modo abierto) si el archivo falla, consistente con la doctrina
796  PID 1 de no romper por subsistemas opcionales.
797
798Ejemplo de archivo de allowlist:
799```text
800# Peers permitidos en la malla brahman de prod-eu-1
801# Generados con: ente-zero (peer_id loggeado al arrancar)
80212D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz
80312D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux  # operador 2
804```
805
806Activación end-to-end:
807```sh
808BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\
809BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\
810ente-zero
811```
812
813Tests:
814- 6 unit en `peer_allowlist::tests`: from_iter, parse limpio, parse
815  con comentarios inline, parse rechaza PeerId inválido (y reporta
816  número de línea), I/O error en archivo faltante, empty list
817  rechaza todo.
818- 1 E2E en `network_libp2p.rs`:
819  `libp2p_handshake_allowlist_admits_listed_rejects_others`. A
820  configura `allowlist = [allowed_peer]`. Cliente con keypair
821  permitida pasa el handshake (sesión registrada, farewell limpio).
822  Segundo cliente con keypair distinta es rechazado con error
823  ANTES de que se le verifique la firma. Sanidad: el conteo de
824  sesiones del server queda en 0 tras el rechazo.
825
82625 tests verdes en brahman-handshake (12 unit + 7 handshake legacy
827+ 3 discovery + 3 libp2p). Ningún regreso en ente-zero (4/4
828keypair_store).
829
830Pendiente futuro:
831- Denylist explícita (negada — banear peers específicos sin tener
832  que listar a todos los demás).
833- Hot reload de la allowlist sin restart del Init (signal SIGHUP o
834  watch del archivo).
835- Aplicar la política a nivel de swarm vía
836  `libp2p_allow_block_list::Behaviour` para rechazar conexiones
837  ANTES del Noise handshake (hoy se rechaza después, gastando un
838  round-trip TCP+Noise por cada intento denegado).
839
840### feat(brahman-net+handshake): stop_providing automático en cleanup de sesión
841Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión
842con outputs cerraba (Farewell, EOF, error), el record que la
843anunciaba en el DHT seguía vivo hasta su TTL natural (~24h en kad
844default). Consumers remotos podían descubrir un peer "vivo" que ya
845no servía nada.
846
847Cambios:
848- **`BrahmanNet::stop_providing(key)`** (nuevo): contraparte simétrica
849  de `start_providing`. Manda `Command::StopProviding` al swarm que
850  llama `kad.stop_providing(&key)`. Borra el record del provider
851  store local al instante; replicas en peers remotos siguen
852  expirando por TTL (kad no expone retracción cross-peer, simétrico
853  al hecho de que `start_providing` también propaga eventualmente).
854- **`brahman_handshake::network::withdraw_outputs(net, card)`**
855  (nuevo): contraparte de `announce_outputs`. Itera `card.flow.output`
856  y llama `net.stop_providing(flow_dht_key(...))` por cada uno.
857- **`server::cleanup`**: extrae la `ResolvedCard` removida del registro
858  de sesiones (en lugar de descartarla con `remove`) y, si
859  `config.net` está set, llama `withdraw_outputs(net, &card)` antes
860  de `broadcast_match_diffs`.
861
862Tests: nuevo E2E `dht_discovery_withdraws_on_session_cleanup`:
8631. A registra Card con `flow.output = monad-list:json`.
8642. B descubre a A vía `find_remote_providers` — confirma
865   `before.contains(&a_peer)`.
8663. Cliente local de A hace `farewell` → cleanup → withdraw_outputs.
8674. Espera a que la sesión salga del registro (señal de cleanup
868   completado) + 100ms para que el swarm procese el Command.
8695. Nueva query desde B: `after` NO debe contener `a_peer`.
870
8713 tests verdes en `network_discovery.rs` (positivo, negativo,
872withdraw). 18 tests totales en handshake + net.
873
874Pendiente futuro: retracción cross-peer en kad (requeriría extensión
875del protocolo libp2p, no soportada hoy). Aceptable: simétrico al
876modelo de propagación eventual del DHT.
877
878### feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p
879Cuarto y último paso del plan "el encuentro entre Entes no se
880restringe a local". Cierra la falla de seguridad que dejaba la red
881P2P abierta: hasta ahora, un atacante que pudiera dial-ar al
882multiaddr del Init podía registrar cualquier Card con cualquier
883label/flow. Fase 3 cierra esto exigiendo que el Hello vía libp2p
884venga firmado con la **misma keypair Ed25519 que produce el
885`peer_id` autenticado por Noise**.
886
887Modelo:
888- **Local Unix**: SO_PEERCRED del kernel autentica al cliente. Firma
889  opcional. Si está presente, igual se verifica (defensa en
890  profundidad).
891- **Remoto libp2p**: firma obligatoria. La public key del Hello debe
892  derivar al `peer_id` que Noise ya autenticó. Si falta o no coincide
893  → `HandshakeError::Unauthorized`.
894
895Wire (`brahman_handshake::messages`):
896- `Hello.signature: Option<HelloSignature>` (nuevo, default None).
897- `HelloSignature { public_key: Vec<u8>, signature: Vec<u8> }` —
898  public_key en formato canónico libp2p (`encode_protobuf`), firma
899  Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option<WitInterface>)`
900  serializado postcard.
901- `SIGNATURE_VERSION = 1` documenta el esquema del payload firmado;
902  bump al cambiar.
903
904Nuevo módulo `brahman_handshake::signature`:
905- `sign_hello(keypair, card, wit) -> HelloSignature`.
906- `verify_hello(sig, card, wit, expected_peer) -> Result<(), SignatureError>`.
907- `SignatureError` tipado (`DecodeKey`, `EncodePayload`, `Invalid`,
908  `PeerMismatch`, `Missing`, `Unexpected`).
909
910Server:
911- `Session<S>` gana `expected_peer: Option<PeerId>`.
912- `Server::session_from_libp2p_stream(stream, peer)` (nuevo)
913  construye Session con `expected_peer = Some(peer)`.
914  `session_from_stream` (Unix/in-memory) sigue con `None`.
915- `do_handshake` exige firma + verifica peer match cuando
916  `expected_peer.is_some()`. Si no, verifica firma presente por
917  consistencia interna pero no exige que esté.
918- `network::run_libp2p_accept_loop` ahora usa
919  `session_from_libp2p_stream(stream.compat(), peer)` para propagar
920  la identidad libp2p al gate de trust.
921
922Client:
923- `Client::connect_with_stream_signed(stream, card, wit, &Keypair)`
924  (nuevo) firma el Hello antes de mandarlo.
925- `Client::connect_with_stream` sigue existiendo sin firma (path
926  Unix / tests).
927- `Client::connect`/`connect_with` (Unix) no cambian — siguen sin
928  firma porque SO_PEERCRED autentica.
929- `network::connect_libp2p(net, peer, card, wit, keypair)`
930  **breaking change**: gana parámetro `keypair: &Keypair`.
931
932BrahmanNet:
933- Almacena la `Keypair` en `Arc<Keypair>` (libp2p Keypair no es
934  Clone; el truco es duplicar el `ed25519::Keypair` interno que sí
935  es Clone, una copia para Noise/swarm y otra para signing).
936- `BrahmanNet::keypair() -> Arc<Keypair>` accessor para que callers
937  puedan firmar con la misma identidad libp2p del nodo sin tener
938  que mantener la keypair por separado.
939- `with_keypair` rechaza keypairs no-Ed25519 (RSA/ECDSA/Secp256k1
940  vendrían a futuro si se necesitan).
941
942Tests:
943- 4 unit en `signature::tests`: roundtrip propio, peer mismatch,
944  card tampered, signature flipped.
945- 1 E2E nuevo en `tests/network_libp2p.rs`:
946  `libp2p_handshake_rejects_mismatched_signing_key` — el cliente
947  intenta firmar con keypair distinta a la del net; server rechaza.
948- E2E positivo (`libp2p_handshake_roundtrip`) ahora pasa la keypair
949  del client_net y debe verificar OK.
950- Discovery + handshake legacy + signature: 90+ tests verdes en
951  brahman-handshake/brahman-net/brahman-card/minga-p2p.
952
953Lo que esto cierra:
954- Brahman-net es una malla públicamente dial-able **con
955  autenticación criptográfica end-to-end**: Noise para el transport,
956  Ed25519 para las Cards.
957- La cadena completa de discovery + connect + trust funciona
958  cross-machine sin paths hardcodeados ni confianza implícita.
959- El plan original ("el encuentro entre Entes no se restringe a
960  local, la ejecución remota está pensada desde el principio")
961  está implementado y testeado.
962
963Pendientes (futuro, no de hoy):
964- `stop_providing` al cleanup de sesión (records DHT viven hasta
965  TTL ~24h).
966- Wire de Arje (`ente-zero`) para arrancar opcionalmente con
967  `BrahmanNet` configurado y `ServerConfig.net = Some(...)`.
968- Allowlist/Denylist de peers (hoy cualquier peer Ed25519-válido
969  pasa el trust gate; producción podría querer un PKI explícito).
970- Persistencia de la keypair de identidad del nodo entre reboots.
971
972### feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type
973Tercer paso del plan "el encuentro entre Entes no se restringe a
974local". Cuando un Init local acepta una sesión cuya Card declara
975outputs, anuncia al DHT (Kademlia, vía `brahman-net`) que él provee
976esos flow types. Cualquier nodo conectado al mismo DHT puede
977consultar y obtener la lista de `PeerId`s que sirven el flow.
978
979API nueva en `brahman_handshake::network`:
980- `flow_dht_key(flow_name, type_ref) -> [u8; 32]`: blake3 hash de
981  `"brahman-flow|v1|{flow}|{type_canon}"`. Determinístico cross-host.
982  Cambiar la canonicalización rompe compatibilidad — el prefijo `v1`
983  documenta la versión del esquema y obliga a bump al modificar.
984- `announce_outputs(net, card)`: llama `start_providing` en el DHT
985  por cada `Flow` en `card.flow.output`. Idempotente, fire-and-forget.
986- `find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>`:
987  query DHT por la key derivada. Lista vacía si nadie anuncia o si
988  la query no resuelve dentro del timeout interno de Kad.
989
990Wire en el server:
991- `ServerConfig` gana `pub net: Option<Arc<BrahmanNet>>`. Si está set,
992  cada Card registrada con outputs se anuncia automáticamente al DHT
993  desde `register_session`. `None` = server "ciego al DHT" (correcto
994  cuando no hay conectividad o el operador no quiere exponer).
995- `ServerConfig` ahora tiene `Debug` manual (BrahmanNet no implementa
996  Debug; loggeamos sólo presencia/ausencia).
997
998Canonicalización del TypeRef:
999- `Primitive { name }` → `prim:{name}`
1000- `Wit { package, interface, name }` → `wit:{package}#{interface_or_empty}#{name}`
1001
1002Tests: 2 nuevos en `tests/network_discovery.rs`:
1003- `dht_discovery_finds_remote_provider`: dos nodos, A registra Card
1004  con `flow.output = monad-list:json`, B dial-ea a A y descubre el
1005  `peer_id` de A vía `find_remote_providers`. Asserts contains.
1006- `dht_discovery_negative_unknown_flow`: B busca un flow que nadie
1007  anunció, devuelve lista vacía sin colgarse.
1008
1009Lo que esto desbloquea:
1010- Un `nouser daemon` corriendo en máquina A puede ser descubierto por
1011  un `nouser-explorer` en máquina B sin conocimiento previo del peer
1012  — sólo necesitan compartir DHT (vía bootstrap inicial).
1013- La cadena completa "explorer → daemon → llm-provider" puede cruzar
1014  máquinas, no sólo procesos.
1015
1016Lo que queda para Fase 3 (trust):
1017- Cards remotas se aceptan hoy sin verificación. Para producción se
1018  necesita firma Ed25519 sobre la Card y verificación antes de
1019  aceptar el Hello remoto. Local sigue confiando en SO_PEERCRED.
1020- Stop-providing al cleanup de sesión (hoy records DHT viven hasta
1021  TTL ~24h aunque la sesión cierre).
1022
1023### feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p
1024Segundo paso del plan "el encuentro entre Entes no se restringe a
1025local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
1026MatchEvent / Farewell, frames postcard length-prefixed) ahora también
1027viaja sobre streams libp2p de la malla `brahman-net` — el mismo Init
1028acepta sesiones por Unix socket Y por libp2p indistintamente, y un
1029consumer remoto puede dial-ar al multiaddr y completar handshake.
1030
1031Cambios:
1032- **`Session<S>` y `Client<S>` genéricos**: ambos dejan de estar atados
1033  a `UnixStream` y pasan a ser genéricos sobre `S: AsyncRead +
1034  AsyncWrite + Unpin + Send + 'static`. El path Unix queda como
1035  `Client = Client<UnixStream>` (default genérico). Constructores
1036  nuevos: `Server::session_from_stream(stream)`,
1037  `Client::connect_with_stream(stream, card, wit)`.
1038- **Refactor del post-handshake con split**: `tokio::select!` sobre
1039  `&mut self.stream` requería `S: Sync` indirectamente, y
1040  `libp2p::Stream` no es Sync. Reemplazado por
1041  `tokio::io::split(stream)` → reader loop principal + writer task
1042  separada que drena el push channel. Writer compartido bajo
1043  `Arc<Mutex<WriteHalf<S>>>` para serializar Pong/Error inline con
1044  los MatchEvents pusheados. Cleanup garantizado en todas las ramas.
1045  La lógica del post-handshake migra a funciones libres
1046  (`run_post_handshake`, `handle_inbound_frame`, `cleanup`,
1047  `broadcast_match_diffs`, `do_handshake`, `register_session`,
1048  `validate_hello`).
1049- **Nuevo módulo `brahman-handshake::network`**:
1050  - `BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"`.
1051  - `LibP2pHandshakeStream = Compat<libp2p::Stream>` (alias del
1052    stream una vez convertido al mundo `tokio::io`).
1053  - `run_libp2p_accept_loop(server, net)`: bucle accept sobre el
1054    protocolo que delega cada stream entrante a una `Session`
1055    construida vía `server.session_from_stream(stream.compat())`.
1056    Sesiones libp2p y Unix conviven en el mismo `Server` —
1057    comparten broker, push table, last_matches.
1058  - `connect_libp2p(net, peer, card, wit)`: abre stream libp2p al
1059    `peer` y arranca handshake.
1060  - `NetworkError` tipado (`OpenStream`, `Handshake`, `AcceptStream`).
1061
1062Deps: `brahman-handshake` gana `brahman-net`, `futures`, `tokio-util`.
1063`brahman-net` re-exporta `Multiaddr`, `PeerId`, `Stream`,
1064`StreamProtocol`, `Protocol`, `OpenStreamError` para que callers no
1065necesiten dep directa a libp2p.
1066
1067Tests:
1068- 9 tests unit + integration verdes (sin regresión en el path Unix).
1069- Nuevo `tests/network_libp2p.rs`: test E2E que arma server con
1070  Unix socket + BrahmanNet, hace listen TCP, monta el accept loop;
1071  cliente con su propio BrahmanNet dial-ea al peer_id, completa
1072  handshake remoto, pinguea, farewell. Verifica que la sesión se
1073  registró durante la conversación y se removió tras farewell.
1074
1075Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo
1076flow type, broker query local + remoto).
1077
1078### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
1079Primer paso del plan "el encuentro entre Entes no se restringe a local".
1080El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
1081a una crate compartida `brahman-net` para que cualquier protocolo de la
1082familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
1083una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.
1084
1085Diseño:
1086- `BrahmanNet::{new, with_keypair}` arma el swarm con DHT en modo
1087  Server, Identify auto-poblando el routing table de Kad, y un
1088  `stream::Control` accesible para que cada protocolo registre su
1089  `StreamProtocol` y abra/acepte streams sin acoplarse al swarm.
1090- API de comandos uniforme: `dial`, `listen`, `add_dht_peer`,
1091  `find_closest_peers`, `start_providing`, `find_providers`.
1092- Pública: `peer_id` (libp2p) + `control` (stream::Control).
1093- Re-exporta `Stream` y `StreamProtocol` para que callers no necesiten
1094  importar libp2p directo.
1095
1096Migración:
1097- `minga-p2p::network` reduce de 282 LOC a 22: ahora sólo re-exporta
1098  `BrahmanNet` bajo el alias histórico `LibP2pNode` (zero churn en
1099  `MingaPeer`) y declara la const `SYNC_PROTOCOL = "/minga/sync/1.0.0"`
1100  específica del sub-protocolo Minga.
1101- Cualquier consumer que necesite armar un nodo P2P puede importar
1102  `brahman_net::BrahmanNet` directo sin pasar por minga.
1103- Deps de `minga-p2p` ganan `brahman-net`; el resto del grafo
1104  (libp2p, libp2p-stream, futures, tokio-util) sigue igual porque
1105  `MingaPeer` aún los usa para la lógica específica de sync.
1106
1107Aclaración semántica anclada por el usuario: **Arje** es el init
1108(PID 1, runtime, ente-zero/kernel/soma); **Brahman** es el encuentro
1109entre Entes (handshake/broker/card/sidecar/ahora también net). El
1110nombre de la crate refleja que la malla pertenece al encuentro, no
1111al runtime — Arje puede usar la malla, Minga usa la malla, cualquier
1112futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.
1113
1114Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior
1115verificado idéntico — sólo se movió código, ningún cambio funcional.
1116Próximo: Fase 1 (handshake brahman sobre libp2p stream).
1117
1118### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path
1119Cierra el único debt estructural detectado en el audit de
1120independencia: `nouser-explorer` ya no arrastra `nouser-core`
1121(que aportaba `notify`/`walkdir`/`sled`/`blake3` al grafo de
1122compilación de una UI que sólo habla JSON contra un socket).
1123
1124Cambios:
1125- **Cliente movido**: `engine_socket::client::list_monads` (~60 LOC,
1126  std + serde_json puros) emigra de `nouser_core::engine_socket` a
1127  `nouser_card::query::client`. Vive donde viven los wire types,
1128  consistente con el principio "un consumer importa el contrato,
1129  no el runtime del productor".
1130- **Drop dep**: `nouser-explorer` deja de dependener de
1131  `nouser-core`. Verificado con `cargo tree`: `notify`, `sled`,
1132  `blake3` desaparecen del grafo del binario. (`walkdir` sigue
1133  pero llega vía `gpui_util` → `rust-embed`, fuera de nuestro
1134  control y pre-existente.)
1135- **Fallback "falla hacia la simplicidad"**: nueva función
1136  `resolve_socket()` en el explorer intenta primero broker
1137  discovery; si el broker no responde / no hay init vivo,
1138  fallback directo a `nouser_card::query::transport::default_socket_path()`.
1139  El explorer queda funcional contra un daemon "huérfano"
1140  (corriendo standalone sin init) — completa la cadena
1141  "consciente cuando hay ecosistema, soberano cuando está solo".
1142- `socket_source` en el header gana un tercer estado
1143  `"default-path"` para que el usuario vea por dónde se conectó.
1144
1145Audit estructural confirmó que el resto del ecosistema ya
1146respeta el principio: todos los `yahweh-*` viewers, `minga-cli`,
1147`minga-core`, `nouser-card`, `nouser-nous`, los providers
1148`nouser-nous-{mock,real}` y `nakui-core` corren standalone con
1149soft-fail hacia infra brahman cuando está ausente. Brahman es
1150"pegamento opcional, no chasis obligatorio" — y ahora el grafo
1151de Cargo lo enforcea, no sólo la convención.
1152
1153Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes.
1154El cliente movido se ejercita end-to-end por los 3 tests integración
1155de `engine_socket` (importa ahora `nouser_card::query::client`).
1156
1157### feat(card): `Card::new(label)` — alternativa segura a `Default::default()`
1158Cierra la trap documentada de `Card::default()` que devuelve `id =
1159Ulid::nil()`. Usar `Card::default()` "viva" colisionaba con cualquier
1160otra Card default-construida bajo el mismo id `00000000…`. La fix no
1161es romper `Default` (sigue siendo determinista, requerido por callers
1162que lo usan como template para deserialización), sino agregar un
1163constructor explícito:
1164
1165  let card = Card {
1166      kind: CardKind::Data,
1167      payload: Payload::Embedded(json),
1168      ..Card::new("mi-modulo.algo")
1169  };
1170
1171`Card::new(label)` asigna `id = Ulid::new()` (único) + `label`
1172provisto, dejando el resto en defaults seguros (Virtual / OneShot /
1173Ente). Pensado para usarse en struct-literals con override parcial,
1174igual sintaxis que el patrón viejo pero sin la trap.
1175
1176Refactor de call sites:
1177- `brahman_sidecar::discovery::build_consumer_card` → `..Card::new(label)`
1178- `nouser daemon::build_engine_card` → `..Card::new("brahman.nouser_engine")`
1179
1180`Default` se mantiene tal cual con docstring expandida que advierte
1181explícitamente sobre el uso "vivo" y apunta a `Card::new`. Tests
1182existentes y el patrón `nouser_card::MonadManifest::to_brahman_card`
1183(que asigna el id estable de la Mónada, no uno fresco) NO se
1184modifican — `Default` sigue siendo correcto cuando el caller
1185sobreescribe `id` explícitamente.
1186
1187Tests: 3 unitarios nuevos en brahman-card (`new_assigns_real_ulid_and_label`,
1188`new_yields_distinct_ids_per_call`, `default_keeps_nil_id_for_struct_update_pattern`).
118915 tests verdes (era 12).
1190
1191### feat(sidecar): API reusable de discovery vía broker
1192Promueve el patrón ad-hoc `discover_producer_socket` (que vivía
1193inline en `nouser attract --remote`) a un módulo público
1194`brahman_sidecar::discovery`. Cualquier consumer puede ahora
1195preguntar al broker "¿quién provee este TypeRef?" con dos llamadas:
1196
1197  // Construir un consumer Card mínimo (Ente, Oneshot, Virtual)
1198  let card = brahman_sidecar::build_consumer_card(
1199      "mi-cli",
1200      "embed-result",   // flow.input.name
1201      "json",            // TypeRef::Primitive { name }
1202  );
1203
1204  // Bloqueante (CLIs, std-thread loops):
1205  let socket: PathBuf = brahman_sidecar::await_provider_blocking(
1206      card, Duration::from_secs(3),
1207  )?;
1208  // O async (módulos con runtime tokio propio):
1209  let socket = brahman_sidecar::await_provider(card, timeout).await?;
1210
1211API:
1212- `build_consumer_card(label, flow_name, type_name) -> Card`
1213  abstrae la verbosidad del struct-literal repetido en cada caller.
1214  Genera un `id: Ulid::new()` real (no nil → seguro contra
1215  colisiones en el broker).
1216- `await_provider(card, timeout) -> Result<PathBuf, ConsumerError>`
1217  conecta al init, espera `MatchEvent::Available`, devuelve
1218  `producer_service_socket`, manda Farewell. Ignora eventos
1219  `Lost` durante el await (no aplican al arranque).
1220- `await_provider_blocking(card, timeout)` arma su propio
1221  runtime `current_thread` para mundos no-async.
1222- `ConsumerError` con variantes tipadas: `Connect { socket, source }`,
1223  `NoProvider { flow, type_ref, timeout }`, `Client(ClientError)`,
1224  `Runtime(String)`. Adiós al `Box<dyn Error>` de antes.
1225
1226Refactor en `nouser daemon`:
1227- `discover_producer_socket` (60 LOC inline en `bin/nouser.rs`) → 5
1228  líneas que delegan en el helper.
1229- `remote_embed` ya no construye su propio runtime tokio.
1230
1231Próximo consumer natural: `nouser-explorer`. Hoy renderea
1232`StatusSnapshot` vía socket admin (introspección pura). El día que
1233quiera **interactuar** con un Ente — p. ej., disparar un re-embed
1234desde la UI — usa este helper para resolver el socket del provider
1235sin hardcodear paths.
1236
1237Nota sobre identidad: este commit fuerza `Ulid::new()` para los
1238consumer Cards generados, evitando la trampa documentada del
1239`Card::default()` que devuelve `Ulid::nil()`. La fijación global de
1240`Default` queda como cleanup separado (requiere auditar que ningún
1241caller dependa del determinismo de `nil`).
1242
1243Tests: 4 unitarios nuevos en `discovery::tests` (id no-nil, id
1244único por llamada, formateo de TypeRef::Wit, fallback sin input).
1245Workspace verde.
1246
1247### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
1248Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
1249Para módulos que publican muchas sesiones (nouser daemon con 50+
1250Mónadas) eso es 50 threads + 50 runtimes. Ahora: **un thread + un
1251runtime tokio current_thread** que hostea N tasks de sidecar.
1252
1253API nueva (aditiva, no rompe `spawn`/`spawn_with_handle`):
1254
1255  let pool = SidecarPool::new()?;
1256  pool.spawn(card1);
1257  pool.spawn(card2);
1258  pool.spawn_conscious(card_wit, wit);
1259  pool.spawn_with_config(SidecarConfig::new(c).with_wit(w));
1260  // pool drop = todas las sesiones cierran.
1261
1262`run_client` se hace pública para que el pool pueda enqueuar tasks
1263externos al runtime con `handle.spawn(run_client(config))`.
1264
1265`nouser daemon` migrado al pool. Verificación con `ps -L`:
1266
1267  $ ps -L -p $(pidof nouser)
1268    LWP   CMD
1269    28817 nouser              # main thread
1270    28819 brahman-sidecar     # pool thread (todas las sesiones)
1271
1272Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar
1273cuántas Mónadas se publiquen.
1274
1275### feat: Crossreferencia — Card.references como grafo del fractal
1276Las Cards ahora declaran sus relaciones con otras Cards. El Engine
1277posee Mónadas; las Mónadas declaran que son poseídas por el Engine.
1278La UI puede cruzar el grafo sin discovery especial.
1279
1280- `brahman-card`:
1281  - `RelationshipKind { Owns, OwnedBy, Processes, ProcessedBy, Sibling }`.
1282  - `CardReference { kind, target_id, target_label }` — `target_label`
1283    es cache del label en el momento de declarar (la UI puede pintar
1284    sin resolver).
1285  - `Card.references: Vec<CardReference>` y espejo en `WireCard`.
1286    Conversiones `From` propagan.
1287- `brahman-broker::BrokeredCard` propaga `references`.
1288- `brahman-status` imprime cada referencia: `ref OwnedBy → label (id)`.
1289- **nouser daemon**: cada Mónada que publica añade
1290  `RelationshipKind::OwnedBy` apuntando al engine. La declaración es
1291  unilateral; el engine no necesita conocer las IDs de antemano.
1292
1293Validación end-to-end:
1294
1295  $ ente-zero & nouser daemon crates/core
1296  $ brahman-status
1297  Sessions (6):
1298    [ente]  ... brahman.nouser_engine
1299    [data]  ... brahman-handshake/src
1300        ref OwnedBy  →  brahman.nouser_engine  (01K...)
1301        summary: 6 archivos...
1302    [data]  ... ente-brain/src
1303        ref OwnedBy  →  brahman.nouser_engine  (01K...)
1304    ...
1305
1306### feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten
1307Cierra el ciclo del swap automático de Nous (mock↔real):
1308
1309- **Schema** (`brahman-card`): `Card.service_socket: Option<PathBuf>` y
1310  espejo en `WireCard`. Conversiones `From` propagan. Es el path del
1311  **data plane** (distinto del socket del Init); cualquier consumer
1312  que matchee con esta Card puede conectar directo sin discovery
1313  adicional.
1314- **Broker** (`brahman-broker`): `BrokeredCard` propaga
1315  `service_socket` desde la Card. Sin participación en el matching —
1316  sólo metadata para los observadores.
1317- **MatchEvent** (`brahman-handshake`): nuevo campo
1318  `producer_service_socket: Option<PathBuf>`. Cuando el server emite
1319  `Available`, busca la `BrokeredCard` del productor en el broker y
1320  copia su `service_socket`. El consumer recibe la ruta completa para
1321  conectar.
1322- **Transport** (`nouser-nous`): `provider_socket_path(provider: &str)`
1323  devuelve `nouser-nous-{provider}.sock` por default — mock y real
1324  coexisten en sockets distintos (Phase D-4). `default_socket_path()`
1325  conserva el comportamiento single-provider.
1326- **Providers**: mock declara `service_socket =
1327  /run/user/X/nouser-nous-mock.sock`; real declara
1328  `nouser-nous-real.sock`. La Card se construye DESPUÉS del bind para
1329  que el path declarado sea el real.
1330- **Status**: `brahman-status` imprime `socket:` por sesión cuando
1331  está presente.
1332
1333Validación end-to-end:
1334
1335  $ ente-zero & nouser-nous-mock & nouser-nous-real &
1336  $ ls /run/user/1001/nouser-nous-*.sock
1337    nouser-nous-mock.sock
1338    nouser-nous-real.sock
1339
1340  $ brahman-status
1341  Sessions (2):
1342    [ente]  ... nouser.nous_real
1343        socket:     /run/user/1001/nouser-nous-real.sock
1344        in  embed-request: Primitive { name: "json" }
1345        out embed-result: Primitive { name: "json" }
1346    [ente]  ... nouser.nous_mock
1347        socket:     /run/user/1001/nouser-nous-mock.sock
1348        in  embed-request, out embed-result
1349
1350Pendientes para futuro (no críticos):
1351- nouser-core attract --remote todavía usa NOUSER_NOUS_SOCKET hardcoded
1352  o `default_socket_path()`. El siguiente paso es subscribirse al
1353  MatchEvent del broker y usar `producer_service_socket` directo —
1354  con eso `BRAHMAN_BROKER_CONTEXT=test/prod` swapea provider sin
1355  tocar al consumer.
1356
1357### feat(broker): priority contexts — biases per-contexto operativo
1358- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
1359  declara un override per-contexto.
1360- `Card.priority_contexts: BTreeMap<String, ContextBias>` y mismo en
1361  `WireCard` (cruza el wire). Las conversiones `From` lo propagan.
1362- `BrokerConfig.current_context: Option<String>`. Cuando el broker corre
1363  bajo un contexto y una Card declara biases para ese nombre, se aplican:
1364  - Como **consumidor**: `pin_to` sobreescribe el `Flow.pin_to` estático.
1365  - Como **productor**: `priority_offset` se suma a la priority base
1366    (clamp en `[Low=0, Critical=3]`) para el ranking.
1367- `BrokeredCard` propaga `priority_contexts`. `find_producer_for` usa
1368  `effective_priority(card)` y `effective_pin(card, input)` antes de
1369  los tiebreaks.
1370- `brahman-admin::AdminConfig.current_context` + `StatusSnapshot.current_context`
1371  espejan el contexto activo. `brahman-status` lo imprime como
1372  `Context: <nombre>` justo debajo de `Init: ...`.
1373- `ente-zero` lee `BRAHMAN_BROKER_CONTEXT` env var y la propaga al
1374  broker y al admin. Sin var, biases per-contexto inactivos.
1375- 4 tests nuevos en brahman-broker:
1376  `context_priority_offset_lifts_producer_above_alphabetic_winner`,
1377  `context_pin_to_overrides_static_pin`, `unknown_context_no_op`,
1378  `priority_offset_clamps_to_critical`.
1379- Validación end-to-end: `BRAHMAN_BROKER_CONTEXT=test ente-zero` →
1380  `brahman-status` muestra `Context: test`.
1381
1382### feat(card): WireCard + extensions — forward-compat sin romper postcard
1383- `Card.extensions: BTreeMap<String, serde_json::Value>` restaurado con
1384  `#[serde(flatten, default, skip_serializing_if = is_empty)]`. Los
1385  campos JSON/TOML desconocidos sobreviven el roundtrip de archivos.
1386- Nuevo `WireCard`: proyección postcard-friendly (sin `extensions`,
1387  `genesis: Vec<WireCard>` recursivo). Conversiones `From<Card>` y
1388  `From<WireCard>` con descarte/recreación de extensions.
1389- `brahman-handshake::Hello.card` pasa de `Card` a `WireCard`. Client
1390  hace `card.into()` antes de enviar; Server hace `hello.card.into()`
1391  para volver a Card antes de validar/registrar.
1392- 3 tests nuevos en brahman-card:
1393  `extensions_preserved_in_json_roundtrip`,
1394  `wire_card_roundtrip_strips_extensions`,
1395  `wire_card_postcard_friendly` (postcard encode/decode efectivo).
1396- brahman-card gana `postcard` como dev-dep para el último test.
1397- Contrato documentado: extensions = anotaciones locales que NO cruzan
1398  al Init; sólo viven en archivos.
1399
1400### `9420eae` chore: limpia warnings dead-code en arje (commit del usuario)
1401- `ente-zero/src/events.rs`: `#![allow(dead_code)]` a nivel módulo —
1402  es vocabulario de eventos con variantes/campos reservados para flujos
1403  no cableados aún (CapabilityRequested, ShutdownReason::Signal,
1404  CapabilityGrant::{Granted, Denied, QuotaExceeded}, ExitStatus
1405  fields).
1406- `ente-zero/src/graph/mod.rs`: comentado el re-export ahora innecesario
1407  de `SHUTDOWN_GRACE`. `DEFAULT_GRANT_TTL` con `#[allow(dead_code)]`
1408  + nota "reservado para capability granting".
1409- `ente-zero/src/graph/capabilities.rs`: `renew_grant` con
1410  `#[allow(dead_code)]` (capability renewal pendiente).
1411- `ente-kernel/src/surface.rs`: drop de `use anyhow::Context` (no se
1412  usaba).
1413- `ente-hostnamed-compat/src/main.rs`: drop de `Connection` (no se
1414  usaba).
1415- `ente-polkit-compat/src/main.rs`: `PolicyDecision.source` con
1416  `#[allow(dead_code)]` (sólo aparece en `Debug` para logging).
1417- `cargo check --workspace`: 17 warnings → 0.
1418
1419### feat(sidecar): WIT al sidecar — módulos conscientes vivos
1420- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
1421  `PartialEq`, `Eq` para cruzar el wire postcard.
1422- `brahman-handshake::Hello` lleva `wit: Option<WitInterface>`. Server
1423  usa `ResolvedCard::from_conscious` cuando viene presente, `from_agnostic`
1424  cuando no.
1425- `brahman-handshake::Client::connect` queda como wrapper agnóstico de
1426  `connect_with(path, card, wit: Option<WitInterface>)`.
1427- `brahman-broker::Broker::register` ahora toma `Option<WitInterface>`
1428  como tercer arg. `BrokeredCard` guarda el wit. 25 sitios de tests
1429  actualizados con `, None`.
1430- `brahman-sidecar::SidecarConfig` con campo `wit`. Helpers nuevos:
1431  `SidecarConfig::new(card).with_wit(wit)` y `spawn_conscious(card, wit)`.
1432  El log `attached` reporta `conscious=true|false`.
1433- `brahman-status` muestra marker 🧠 + sección `wit:` (package/world,
1434  imports, exports) por sesión consciente.
1435- Example nuevo `crates/shared/brahman-sidecar/examples/presence-conscious.rs`:
1436  toma label + path .wit (default `shared_wit/protocol.wit`), parsea
1437  con brahman-card-wit, spawna sidecar consciente.
1438- Validado end-to-end:
1439  ```
1440  $ presence-conscious demo.conscious shared_wit/protocol.wit &
1441  $ brahman-status
1442  Sessions (1):
1443    01K... demo.conscious 🧠  lifecycle=Daemon
1444        wit: brahman:protocol@0.1.0 / module
1445             imports: types, handshake, lifecycle
1446             exports: run
1447  ```
1448
1449### feat(core): brahman-card-wit — extractor opcional de contratos WIT
1450- Crate nuevo `crates/core/brahman-card-wit` con `wit-parser = "0.230"`.
1451- API: `parse_wit(source)` y `parse_wit_file(path)` devuelven
1452  `Vec<WitInterface>` (uno por `world` declarado).
1453- Interfaces importadas/exportadas (no sólo funciones) se resuelven
1454  por nombre via `resolve.interfaces[id].name`.
1455- Example `crates/core/brahman-card-wit/examples/brahman-wit-info.rs`
1456  CLI: `brahman-wit-info shared_wit/protocol.wit` → lista paquete,
1457  worlds, imports y exports.
1458- 4 tests: inline, archivo real (`shared_wit/protocol.wit`), parse
1459  error, world vacío.
1460- Validado contra `protocol.wit`: detecta worlds `module` y
1461  `admin-host` con sus imports/exports correctos.
1462
1463### `7b589b8` chore: agrega CHANGELOG.md retroactivo
1464- `CHANGELOG.md` en la raíz con los 11 commits previos documentados
1465  acción por acción. A partir de este punto, cada cambio sustantivo
1466  actualiza también este archivo en el mismo commit.
1467
1468### `8a83a26` feat(handshake): notificación push de matches
1469- Frame `MatchEvent { kind: Available | Lost, ... }` añadido al protocolo.
1470- `Session::run_post_handshake` usa `tokio::select!` para multiplexar
1471  reads del cliente y un canal `mpsc` push del server.
1472- Server: `SessionTxTable` (Arc<Mutex<HashMap<SessionId, Sender<Frame>>>>)
1473  y `LastMatches` para diff por sesión. `broadcast_match_diffs` corre
1474  tras cada `register` y `unregister`, emite sólo los cambios.
1475- Capacity del canal push: 32 (ephemeral, `try_send` non-blocking).
1476- Client: `VecDeque<MatchEvent>` interno, `take_event()` (non-blocking)
1477  y `await_event(timeout)`. `ping()` ahora drena MatchEvents intermedios
1478  hasta encontrar el Pong.
1479- Example `crates/core/brahman-handshake/examples/subscriber.rs`.
1480- Test `match_event_pushed_on_producer_arrival` (handshake integ 6→7).
1481
1482### `70a7a0d` feat: segundo módulo (nakui) + admin API + brahman-status
1483- Crate nuevo `crates/shared/brahman-sidecar` (DRY del thread + tokio +
1484  ping loop). API: `spawn(card)` / `spawn_with_handle(config)`.
1485- `nakui` cmd_run llama `brahman_sidecar::spawn` antes de `run_server`.
1486  Card: lifecycle Daemon, supervision Restart, flow `command` (json) /
1487  `report` (json).
1488- Crate nuevo `crates/core/brahman-admin` con `StatusSnapshot` JSON
1489  line-delim, `AdminServer` y `client::query`.
1490- ente-zero levanta también el AdminServer en `primordial_loop`.
1491- Example `crates/shared/brahman-sidecar/examples/presence.rs`
1492  (módulo dummy long-lived parametrizable por label).
1493- Example `crates/core/brahman-admin/examples/brahman-status.rs`
1494  (CLI que pretty-printa el snapshot).
1495- `brahman-broker`: `BrokeredCard` ahora incluye `lifecycle`. `Endpoint`
1496  y `Match` derivan `Serialize`/`Deserialize`. Nuevo `Broker::cards()`
1497  iterador.
1498- `brahman-card`: `pub use ::ulid` para que módulos no dependan de ulid.
1499- yahweh-shell migrado al sidecar compartido (96→53 LOC).
1500
1501### `595f68e` feat(yahweh-shell): primer módulo brahman vivo
1502- yahweh-shell spawnea sidecar antes de `Application::new()`.
1503- Card declarada: label `brahman.ui_engine`, lifecycle Widget,
1504  supervision Delegate, payload Virtual, flow input `render-data`
1505  (json) / output `user-intent` (json).
1506- Sidecar en thread aparte con tokio current_thread runtime,
1507  desacoplado del runtime GPUI.
1508
1509### `df9d10c` feat(ente-zero): enchufa el handshake server al Init real
1510- ente-zero levanta `brahman_handshake::server::Server::bind` en
1511  `primordial_loop` después del ente-bus, con degradación grácil
1512  si bind falla (mismo patrón que uevents).
1513- Nuevo módulo `brahman-handshake/src/transport.rs`: helper
1514  `default_socket_path()` con resolución `BRAHMAN_INIT_SOCKET` →
1515  `XDG_RUNTIME_DIR` → `TMPDIR`.
1516- Example `crates/core/brahman-handshake/examples/probe.rs`.
1517- Validación end-to-end manual: probe contra ente-zero vivo
1518  imprime `HelloAck: session=... init_attached=true`.
1519
1520### `07d77a3` feat(handshake): integra el broker con el ciclo de sesiones
1521- `ServerConfig` acepta `Option<Arc<Mutex<Broker>>>`.
1522- `register_session` indexa la Card en el broker y la `SessionRegistry`
1523  antes de emitir HelloAck.
1524- `Session::handle` refactor a `do_handshake → run_post_handshake →
1525  cleanup` con cleanup unificado (broker + sessions).
1526- Tests integ nuevos: `broker_registers_and_unregisters_with_session`
1527  y `broker_matches_two_live_modules`.
1528- Fix colateral: `brahman-card::TypeRef` pasa de internally-tagged
1529  (`#[serde(tag = "kind")]`) a externally-tagged. Postcard no soporta
1530  internally-tagged en formatos no self-describing. JSON cambia de
1531  `{"kind":"primitive","name":"x"}` a `{"primitive":{"name":"x"}}`.
1532
1533### `5091106` feat(core): brahman-broker — matching híbrido
1534- Crate nuevo `crates/core/brahman-broker`.
1535- 3 estrategias de matching: `Exact`, `Structural`, `ExactThenStructural`
1536  (default). Devuelven `Match::via` con la estrategia que ganó.
1537- Override `pin_to`: el consumer pide un productor por label; si la
1538  pista no resuelve, cae en type-search.
1539- Tiebreak por `Card.priority` desc, luego `label` asc (estable y
1540  determinista).
1541- API: `register`, `unregister`, `find_producer_for`, `all_matches`,
1542  `cards`, `sessions`, `len`, `is_empty`.
1543- 11 tests (matching, pin_to, priority, no-self-loops, all-matches).
1544
1545### `814390f` feat(core): brahman-handshake — protocolo runtime
1546- Crate nuevo `crates/core/brahman-handshake` con server y client
1547  Rust↔Rust sobre Unix socket.
1548- Frames length-prefixed (4 bytes LE) + cuerpo postcard.
1549- Mensajes: `Hello`, `HelloAck`, `Ping`, `Pong`, `Farewell`, `Error`.
1550- `MAX_FRAME_BYTES = 4 MiB` para evitar reservas absurdas.
1551- Tradeoff: drop `extensions`/`extra` de Card por incompat
1552  postcard ↔ `serde_json::Value`. Forward-compat queda en
1553  `schema_version` + `protocol_version` negotiation.
1554- 4 tests integ + 1 unit en codec.
1555
1556### `ed0e973` refactor(arje): migra ente-card a re-export de brahman-card
1557- `ente-card/src/lib.rs` reescrito como crate-shim de re-export
1558  (327 LOC → 25 LOC).
1559- `EntityCard` ≡ `brahman_card::Card` por type alias.
1560- `ente-card/Cargo.toml`: deps reducidas a `brahman-card`.
1561- `Card` impl `Default` (Ulid::nil(), label vacío) para que
1562  `..Default::default()` funcione en struct-literals.
1563- 4 sitios en `ente-zero/src/seed.rs` actualizados con
1564  `..Default::default()` para los campos aditivos.
1565- Los 21 consumidores arje compilan sin tocar fuente.
1566
1567### `0feba74` feat(core): brahman-card — Tarjeta canónica híbrida
1568- Crate nuevo `crates/core/brahman-card`.
1569- Hereda de arje: `id: Ulid`, `lineage`, `Capability` tipado,
1570  `Payload::{Wasm, Native, Virtual, Legacy}`, `SomaSpec`
1571  (namespaces, cgroups, rlimits, cpu_affinity), `Supervision`
1572  (Restart con backoff, OneShot, Delegate), `genesis` recursivo.
1573- Aditivo brahman: `Permissions` enumerados (`NetworkingPolicy`,
1574  `FsPolicy`, `IpcPolicy`), `Lifecycle` ortogonal a Supervision,
1575  `Priority` de scheduling, `Flows` con `TypeRef` discriminado
1576  (Primitive | Wit), `pin_to` opcional.
1577- `TrustLevel` derivado de `Permissions` (no declarado).
1578- `ResolvedCard { card, wit: Option<WitInterface>, trust }`.
1579- Soporta JSON (canónico) + TOML (auto-detectado por extensión).
1580- 8 tests incluido `arje_seed_format_compatible` que valida que
1581  el JSON de arje sigue parseando con defaults para los aditivos.
1582
1583### `4d50bfc` chore: absorbe nakui (ERP matemático) en modules/nakui
1584- `~/nakui` → `crates/modules/nakui/{core,modules}`.
1585- `core/`: el crate `nakui-core` con 4 bins (nakui, demo,
1586  inventory_demo, sales_demo) y tests.
1587- `modules/{inventory,sales,treasury}/`: data declarativa
1588  (`nsmc.json`, `schema.k`, `morphisms/`) que el crate consume.
1589  No son crates Cargo.
1590- Deps directas (no `workspace = true`): thiserror v1, surrealdb,
1591  rhai, petgraph. No conflicto con el resto del workspace.
1592
1593### `53dbdf0` chore: monorepo inicial con arje + minga + yahweh absorbidos
1594- 45 crates absorbidos en 4 ejes:
1595  - `crates/core/`: 24 crates de arje (Init systemd-compatible:
1596    `ente-card`, `ente-zero`, `ente-kernel`, `ente-bus`, `ente-cas`,
1597    `ente-soma`, `ente-wasm`, `ente-snapshot`, `ente-brain`,
1598    `ente-echo`, `ente-policy-provider`, + 12 `*-compat`).
1599  - `crates/modules/semantic_dht/`: 5 crates de minga (`minga-core`
1600    con AST/CAS/MST, `minga-p2p` con libp2p Kad, `minga-store`,
1601    `minga-vfs`, `minga-cli`).
1602  - `crates/modules/ui_engine/`: 11 crates de yahweh (libs/{core,
1603    theme, bus, providers}, widgets/{tree, splitter, tabs, tiled,
1604    container_core, text_input}).
1605  - `crates/apps/`: 5 crates de yahweh (file_explorer,
1606    database_explorer, text_viewer, image_viewer, yahweh-shell).
1607- `shared_wit/protocol.wit` con handshake/lifecycle inicial.
1608- `Cargo.toml` unificado: thiserror bumped a 2 (transparente para
1609  arje), tokio "full", paths intra-workspace de yahweh redirigidos.
1610- `cargo check --workspace`: 0 errores (sólo dead-code warnings
1611  preexistentes en ente-zero).
1612

protocol.md

Changelog — protocol/

Contratos canónicos + routing entre módulos. Antes: core/brahman-* + shared/brahman-*.

feat(brahman-auth): SessionTicket — el tiquet greeter→compositor

brahman_auth::SessionTicket { user: UserInfo, session: String }: lo que el greeter le entrega al compositor tras un login exitoso. to_line/from_line lo serializan a una línea única (campos por tabulador, prefijo versionado MIRADA-SESSION-TICKET-v1) — el greeter la imprime a stdout y el compositor escanea sus líneas buscando el prefijo. 5 tests de round-trip y rechazo de líneas malformadas.

feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock

Crate nuevo crates/protocol/brahman-auth: la base del DM/greeter de carmen (mirada). Contrato Authenticator agnóstico del backend: authenticate(usuario, secreto) -> UserInfo, donde UserInfo lleva uid/gid/home/shell — lo que el compositor necesita para arrancar la sesión.

feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers

Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o future-me retomando el repo) puede levantar el escenario completo con un comando.

Crate nuevo crates/apps/brahman-demo/ con 3 binarios:

Variables de entorno respetadas en los 3: BRAHMAN_INIT_SOCKET, BRAHMAN_BROKER_CONTEXT (sólo broker), BRAHMAN_DEMO_LABEL/FLOW/TYPE, RUST_LOG.

Script nuevo scripts/bootstrap-demo.sh:

Smoke verificado end-to-end (sin DISPLAY, sólo backend):

Stack tests: brahman-demo (0 unit), workspace verde.

feat(brahman-handshake): ListMatches endpoint + timeline en broker-explorer

Iter 21. Cierra el loop de observabilidad iniciado en iter 20: ahora se ven no sólo las sesiones registradas sino también qué matches consumer↔producer está computando el broker en cada momento, y la historia de cómo cambian.

brahman-handshake/messages.rs:

brahman-handshake/server.rs:

brahman-handshake/client.rs: Client::list_matches() análogo a list_sessions(), drena MatchEvents intermedios al buffer.

brahman-sidecar/discovery.rs: list_matches y list_matches_blocking con la misma forma de Card observer minimalista.

brahman-broker-explorer:

Tests broker-explorer: 5 totales.

Decisión: timeline polled (cada POLL_INTERVAL=5s), no push. Razón: los MatchEvent push del broker son consumer-céntricos (cada session sólo ve sus propios matches). Para "system-wide timeline" haría falta una API broker-level "subscribe a todos" — mucho más scope. Polling cada 5s es suficiente para observabilidad.

feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer

Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier módulo conectado puede preguntar al broker la lista de sesiones activas y mostrar labels + flows in/out por cada una.

brahman-handshake/messages.rs:

brahman-handshake/server.rs:

brahman-handshake/client.rs:

brahman-sidecar/discovery.rs:

brahman-broker-explorer:

Tests:

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

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

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