gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Sutichaykuna

⎇ b31f98883379a172efd309cd2cec98d16eb591db docs/changelog/protocol.md Markdown · 1580 siq'ikuna · 75.4 KB Ñawpa willay ch'uya

refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG 550c98f27 · sergio ·

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

protocol.md

Changelog — protocol/

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

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