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