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