# Changelog Registro cronológico de cambios sustantivos en el monorepo Brahman. Cada entrada lista las acciones concretas tras un commit; para detalles de ratio/diff ver `git show `. ## 2026-05-09 ### 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>, deny: BTreeSet)` — 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): 1. Si `peer ∈ denylist` → `DeniedByDenylist`. 2. Si hay allowlist y `peer ∉ allowlist` → `NotInAllowlist`. 3. Resto → `Admit`. Esto significa que **deny gana sobre allow**: un peer en ambas listas es rechazado. Diseño explícito para que la denylist sea la primitiva de "kill switch" — agregar un peer al deny lo banea inmediatamente sin importar dónde más esté listado. Watcher: vigila el **directorio padre** del archivo, no el archivo mismo. Razón: editores típicos hacen rename-and-replace (escriben a tmp y rename al destino), lo que rompe el watch del archivo pero no el del dir. Filtra eventos por path al procesar. Wire en server: - `ServerConfig.allowlist` → `ServerConfig.policy: Option` (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: ```sh 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`. `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: ```text # 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: ```sh 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`: 1. A registra Card con `flow.output = monad-list:json`. 2. B descubre a A vía `find_remote_providers` — confirma `before.contains(&a_peer)`. 3. Cliente local de A hace `farewell` → cleanup → withdraw_outputs. 4. Espera a que la sesión salga del registro (señal de cleanup completado) + 100ms para que el swarm procese el Command. 5. Nueva query desde B: `after` NO debe contener `a_peer`. 3 tests verdes en `network_discovery.rs` (positivo, negativo, withdraw). 18 tests totales en handshake + net. Pendiente futuro: retracción cross-peer en kad (requeriría extensión del protocolo libp2p, no soportada hoy). Aceptable: simétrico al modelo de propagación eventual del DHT. ### feat(ente-zero): wire de Arje con brahman-net (red P2P opcional + identidad persistente) Cierra el último pendiente del plan de red: Arje ahora puede arrancar opcionalmente con `BrahmanNet` configurado, persistir su identidad libp2p entre reboots, y participar en la malla brahman como nodo público. Sin breaking changes: usuarios actuales (sin env vars) siguen viendo el comportamiento Unix-only de antes. Activación por env vars: - **`BRAHMAN_LISTEN_MULTIADDR`** — si set, activa la red P2P. Ej: `/ip4/0.0.0.0/tcp/4101` (público), `/ip4/127.0.0.1/tcp/0` (loopback, port aleatorio). Sin la var, `brahman_net = None` y todo sigue como antes. - **`BRAHMAN_KEYPAIR_PATH`** — override del path donde se persiste la keypair Ed25519 de identidad libp2p del nodo. Defaults sensatos: - PID 1 (root): `/var/lib/brahman/init-keypair.bin`. - Dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin` → `$HOME/.local/share/brahman/init-keypair.bin` → `/tmp/brahman-init-keypair.bin` (último recurso). - **`BRAHMAN_BOOTSTRAP_PEERS`** — lista coma-separada de multiaddrs para dial-ear al arranque y entrar al DHT. Sin esto, el nodo arranca aislado hasta que alguien se conecte a él. Comportamiento al activarse: 1. `keypair_store::load_or_generate(path)` carga la keypair de disco o genera+persiste una nueva (32 bytes raw, permisos 0o600, atomic rename). Reboots conservan el `peer_id`. 2. `BrahmanNet::with_keypair(kp)` arma el swarm con esa identidad. 3. `net.listen(multiaddr)` espera dirección resuelta y la loggea. 4. `BRAHMAN_BOOTSTRAP_PEERS` (si set) → dial a cada multiaddr. 5. El handshake server se levanta con `ServerConfig.net = Some(net)`, que activa `announce_outputs` automático en el DHT por cada Card con outputs. 6. Además del Unix accept loop (existing), se monta un libp2p accept loop sobre el mismo `Server` compartido. Sesiones locales y remotas conviven en las mismas tablas (sessions, push_table, broker, last_matches). Refactor del Unix accept loop: antes consumía el server vía `server.run().await`; ahora usa `Arc::accept_one().await` en loop para coexistir con el libp2p accept loop sin moverse el server. Degradación grácil en cada paso: si la keypair no carga, si el multiaddr es inválido, si el listen falla, si el bootstrap dial revienta — loggeamos y seguimos en modo Unix-only. La doctrina de PID 1 ("ningún subsistema opcional rompe el bucle primordial") se mantiene. Tests: 4 unit en `keypair_store`: - `generate_persist_and_reload_yields_same_peer_id` — peer_id estable across reloads (la propiedad fundamental). - `rejects_corrupted_file` — archivo de tamaño incorrecto rechazado. - `persisted_file_is_owner_only` — permisos 0o600 verificados. - `default_path_honors_env` — `BRAHMAN_KEYPAIR_PATH` override respeta tanto dev como root mode. Ente-zero compila clean. Ningún test del workspace regresa. Lo que esto desbloquea hoy: - Para activar Arje como nodo público, basta: ```sh BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 ente-zero ``` - Cualquier consumer (en otra máquina) puede luego dial-ar a ese multiaddr + descubrir Cards anunciadas via DHT + abrir handshake remoto firmado. - La identidad del nodo (su `peer_id`) sobrevive reboots, así que los nodos remotos pueden cachear "este peer_id es Arje en máquina X" sin invalidarse cada vez. Pendientes futuros: - `stop_providing` al cleanup de sesión (records DHT con TTL ~24h). - Allowlist/Denylist de peers (PKI explícito). - Rotación de keypair sin perder peer_id (multi-key identity). ### 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` (nuevo, default None). - `HelloSignature { public_key: Vec, signature: Vec }` — public_key en formato canónico libp2p (`encode_protobuf`), firma Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option)` 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` gana `expected_peer: Option`. - `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` (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` 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 `PeerId`s 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`: 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>`. 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` y `Client` 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` (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>>` 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` (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(explorer+daemon): discovery dinámico vía broker + query socket La UI deja de hardcodear el socket admin: ahora descubre al daemon nouser vía `MatchEvent::Available` del broker brahman y le consulta sus Mónadas directo, sin pasar por brahman-admin. Cierra el "explorer encuentra al daemon de forma totalmente dinámica" del meta-plan. Pipeline end-to-end: - Daemon publica engine Card con `service_socket = $XDG_RUNTIME_DIR/nouser-engine.sock` y `flow.output = monad-list:json`. - Daemon binda un Unix socket en ese path y monta un listener blocking que sirve `nouser_card::query::QueryRequest::ListMonads`, responde `ListMonadsResponse { engine, monads: Vec }`. - Explorer construye un consumer Card con `flow.input = monad-list:json` vía `brahman_sidecar::build_consumer_card`, llama `await_provider_blocking(card, 3s)` y recibe el socket descubierto. - Cachea ese socket; cada poll (2s) llama `nouser_core::engine_socket::client::list_monads(socket, 2s)`. Fallo de query → invalida cache → próximo tick re-descubre. Wire types nuevos en `nouser_card::query`: - `QueryRequest::ListMonads` (single variant por ahora). - `ListMonadsResponse { engine: EngineInfo, monads: Vec }`. - `MonadView`: proyección slim de `MonadManifest` SIN `centroid` ni `members` — la UI no los necesita y eran KB por Mónada que no tenían por qué viajar cada poll. - `transport::default_socket_path()` con env override `NOUSER_ENGINE_SOCKET`. - Const `FLOW_MONAD_LIST = "monad-list"`, `FLOW_TYPE_NAME = "json"`. Listener en `nouser_core::engine_socket`: - `spawn_listener(config, db)` arma std::os::unix::net::UnixListener en thread blocking dedicado. Frecuencia esperada (UI cada 2s) no amerita tokio. - `client::list_monads(socket, timeout)` — cliente blocking con `QueryError` tipado (Connect / Io / Serde / Daemon / Timeout / Empty). - 3 tests integración: roundtrip vacío, Mónadas reales, request inválido devuelve ErrorResponse. Refactor explorer: - Drop dep `brahman-admin`, add deps `brahman-sidecar`, `nouser-card`, `nouser-core`. - State: `socket: Option` cache + `snapshot: Option` + `socket_source: "discovery"|"cache"` (sólo informativo). - Tick: `tick(prior_socket)` separado del UI, devuelve un enum `TickOutcome::{Ok, DiscoveryFailed, QueryFailed}`. Cualquier fallo invalida la cache → re-discovery automática. - Header reformulado: `Engine 'nouser_engine' · N mónada(s) · socket: /... (cache|discovery) · watching: /tmp/x`. - Render pintado de un engine card + Mónadas, sin ya iterar `BrokeredCard` del admin. Trade-offs aceptados: - Polling 2s (no streaming). El broker no empuja cambios de Data cards hoy; agregar streaming requiere extender el protocolo handshake. Para snapshot UI, polling 2s es suficiente. - Re-descubrimiento full en cada error de query (en lugar de retry con backoff). Discovery es barato (~ms vs broker), no vale la pena la complejidad. Tests: 10 (nouser-card, +3 query) + 27 (nouser-core, +3 engine_socket) + 4 (sidecar) verdes. Explorer compila clean. ### feat(nous-real): cache de embeddings + write-through al CAS de arje Cierra el ciclo de la crítica del usuario: "Si un archivo no ha cambiado su hash en el CAS, Nouser ni siquiera debería pedirle al LLM que re-genere el embedding". El modelo real (`fastembed-allMiniLML6V2-384d`, ~1-50ms por archivo) era invocado ciegamente en cada re-cluster del watcher. Ahora se cachea por `sha256(bytes-vistos) + model_id`. Pipeline en `handle_file`: 1. Lee primeros 8 KiB (igual que antes). 2. `file_sha = ente_cas::sha256_of(buf)` — hash de los bytes que el modelo *realmente* verá (no del archivo completo). Garantiza que un archivo creciendo más allá de la ventana sin tocar la cabeza siga sirviendo cache hits. 3. Cache lookup: HIT → respuesta en ~µs. 4. MISS → `ente_cas::store(&buf)` (write-through al CAS de arje, no-fatal si falla) → `backend.embed_one(text)` → `cache.put(...)`. Backend de cache: sled local en `$XDG_CACHE_HOME/brahman/nouser-nous-real-embed-cache.sled`. Tree versionado `embed_cache_v1`; el `MODEL_ID` viaja en la key, así que cambiar de modelo invalida el cache implícitamente. Override por env `NOUSER_NOUS_REAL_CACHE`. Encoding compacto: cada `Vec` se serializa como bytes little-endian (4B por f32, sin overhead). Para el modelo default (384-d) son 1.5 KiB por entry. Decode tolera bytes corruptos (longitud no-múltiplo de 4 → `None`, no panic). Por qué sled y no `ente-cas` directo: el CAS de arje es flat sha256-keyed; la cache necesita un mapeo `(file_sha, model_id) → embedding`, no expresable como entry CAS. El write-through a CAS queda como registro consultable + futura GC. API: - `EmbedCache::open()` → abre sled, idempotente. - `EmbedCache::open_at(dir)` para tests. - `EmbedCache::get(sha, model)` → `Option>`. - `EmbedCache::put(sha, model, &[f32])` → no-fatal en error. - `EmbedCache::len()` → contador para logs (best-effort). Mock NO se modifica — su embedding pseudo-32d es metadata-hashing puro, sin costo. Cachearlo sería overhead. Tests: 5 unitarios (`roundtrip_returns_same_vector`, `miss_returns_none`, `different_models_do_not_collide`, `different_content_different_keys`, `corrupted_value_returns_none`). Verdes con `--features embeddings`; stub mode (sin feature) sigue compilando sin tocar cache. ### chore(nakui): alinear `nakui-core` con `[workspace.package]` y deps compartidas Cleanup de drift de convenciones: `nakui-core` era el único crate del monorepo que mantenía `version = "0.1.0"` / `edition = "2021"` / `thiserror = "1"` hardcoded, mientras el resto heredaba del workspace y usaba `thiserror = "2"`. Eso significaba que un bump global de versión o de edition se olvidaba sistemáticamente de nakui. Cambios: - `[package]`: `version`, `edition`, `rust-version`, `license`, `authors`, `publish` → todos `*.workspace = true`. Agregado `description` (cumple convención del resto de crates). - Deps compartidas migradas a `{ workspace = true }`: serde, serde_json, thiserror (v1→v2), tokio, ulid, sha2. - `uuid` migrado a `{ workspace = true, features = ["serde"] }` — la feature `serde` no está en el workspace dep porque nakui es el único user; queda local opt-in en lugar de inflar el dep común. - Deps específicas de nakui (sin compartición posible): rhai, petgraph, surrealdb permanecen inline con versión local. Verificación: `cargo build -p nakui-core` verde tras el bump de `thiserror` v1→v2 — el `#[derive(Error)]` de los 14+ enums de error en nakui no requirió ajustes (la API de derive es backwards-compatible para los patrones simples). `cargo test -p nakui-core --lib`: 27/27 verdes, sin regresión. ### feat(card): `Card::new(label)` — alternativa segura a `Default::default()` Cierra la trap documentada de `Card::default()` que devuelve `id = Ulid::nil()`. Usar `Card::default()` "viva" colisionaba con cualquier otra Card default-construida bajo el mismo id `00000000…`. La fix no es romper `Default` (sigue siendo determinista, requerido por callers que lo usan como template para deserialización), sino agregar un constructor explícito: let card = Card { kind: CardKind::Data, payload: Payload::Embedded(json), ..Card::new("mi-modulo.algo") }; `Card::new(label)` asigna `id = Ulid::new()` (único) + `label` provisto, dejando el resto en defaults seguros (Virtual / OneShot / Ente). Pensado para usarse en struct-literals con override parcial, igual sintaxis que el patrón viejo pero sin la trap. Refactor de call sites: - `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` 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` 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(nouser+sidecar): watcher con debounce + re-publish al broker Cierra las dos limitaciones del watcher previo: ya no spamea N veces por una sola edición, y el broker ve los cambios estructurales en lugar de quedarse con manifests congelados al arranque. $ nouser daemon /tmp/x & $ touch /tmp/x/src/a.rs /tmp/x/src/b.rs /tmp/x/src/c.rs # daemon log (un solo batch, no 9 reacciones): [watcher] ⚙ batch: 6 path(s) coalescidos → re-scan [watcher] ✦ x/src nace (3 miembros, lens=Code) [watcher] ⌃ delta: 1 nuevas, 0 refrescadas, 0 cerradas — 3 sesiones vivas Mecánica del debounce (150ms): - `spawn_fs_watcher` arma dos threads: **dispatcher** filtra eventos notify Create/Modify/Remove a un canal de paths; **coordinator** mantiene `HashMap` y dispara batch sólo cuando todos los paths llevan ≥150ms quietos. - Un `:w` típico de vim (~5 eventos por archivo) colapsa a 1 batch. Mecánica del re-publish: - `SidecarPool` ahora trackea `HashMap` indexado por `Card.id`. Llamar `pool.spawn(card)` con un id ya presente aborta la sesión previa y abre una nueva — `spawn` se vuelve idempotente: re-publicar una Mónada cuya composición cambió refresca su sesión en el broker sin dejar zombies. - Nueva API `pool.drop_session(id)` para cerrar una sesión explícitamente cuando una Mónada desaparece (directorio quedó bajo `min_files` o se borró). - `pool.live_sessions()` para introspección/logs. - `process_change_batch` re-scanea + re-clusteriza con hidratación, diffea contra prior_monads, y para cada Mónada decide: - removida → `drop_session` - nueva → `spawn` con ✦ - composición cambió (members o centroid distintos) → `spawn` con ↻ - idéntica → no-op Trade-off aceptado: re-scan global por batch (no incremental). Es O(N archivos) por evento y para árboles típicos (<10k) cae en <100ms. Optimizar a re-cluster parcial cuando duela. Tests: workspace completo verde. ### feat(nouser): notify watcher — el sistema reacciona en tiempo real El daemon ahora monta un `notify::recommended_watcher` recursivo sobre el directorio. Cada `Create`/`Modify` de archivo regular dispara: embedding del archivo, filtro por `centroid_model`, ranking contra centroides existentes, log con marker 🧲 / · según supere el umbral de atracción. $ nouser daemon /tmp/x & # en otra terminal: $ vim /tmp/x/src/nuevo.rs # daemon log: [watcher] 🧲 /tmp/x/src/nuevo.rs → x/src (0.7470) $ echo "edit" >> /tmp/x/docs/n1.md [watcher] 🧲 /tmp/x/docs/n1.md → x/docs (0.8169) Mecánica: - DB pasa a `Arc>` para sharing con el thread del watcher. - Watcher en thread dedicado (`nouser-watcher`); reacciona sólo a Create/Modify, ignora Access/Metadata-only. - `react_to_change(path, metadata, db)` computa embedding, filtra por `centroid_model`, busca best attraction. - No re-publica al broker ni muta DB — sólo observa y narra. La invalidación selectiva (re-cluster + replace_monads + diff publish) queda como work futuro. Limitación conocida: `notify` emite múltiples eventos por una sola edición (Create + Modify, etc.). Sin debounce, el watcher reporta varias veces. Aceptable para demo; production conviene debounce ~100ms por path. Tests: 7 (card) + 24 (core) verdes, 0 errores, 0 warnings. ### feat(nouser): hidratación del daemon vía sled + path_hint El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene Mónadas previas con `centroid_model` válido, las publica instantáneo y el re-scan reusa sus IDs vía `path_hint`. Schema: - `MonadManifest.path_hint: Option` — identidad estable derivada del origen (para `by_directory`, el parent dir canónico). Permite reusar ULID across re-scans. Algoritmo (cluster): - Nueva fn `cluster::by_directory_hydrated(files, min_files, prior: Option<&MonadDb>)`. Cuando hay `prior`, busca Mónada con mismo `path_hint` Y mismo `centroid_model`; si la encuentra, reusa `id`, `lineage` y `created_at_ms`. - `by_directory` queda como wrapper sin hidratación (back-compat). Daemon (cmd_daemon): 1. Open sled si NOUSER_DB_PATH existe. 2. Publica las Mónadas previas con `centroid_model` válido (las inválidas se descartan con log explícito). 3. Re-scan + `by_directory_hydrated(prior=&db)`. 4. Sólo spawnea sidecars para Mónadas con id que NO estaba en la hidratación inicial. Los path_hints existentes preservan identidad, evitando duplicados en el broker. 5. Persiste el set actualizado. Validación end-to-end: $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core # arranque 1: DB vacía re-scan 102 archivos → 5 mónadas 1 ente + 5 mónadas vivas (5 nuevas vs hidratación) $ NOUSER_DB_PATH=/tmp/h.sled nouser daemon crates/core # arranque 2: DB poblada hidratadas 5 mónadas previas en O(1) re-scan 102 archivos → 5 mónadas 1 ente + 5 mónadas vivas (0 nuevas vs hidratación) Costo del arranque 2: ~0.06s user CPU. Antes (sin hidratación) era re-scan + cluster + spawn x N — segundos enteros para árboles grandes. Tests: 7 (card) + 24 (core) verdes. ### feat(nouser): centroid_model — versionado de embeddings Protege contra el bug silencioso de mezclar centroides de modelos distintos (mock 32-d vs real 384-d), que daba scores sin sentido. - `MonadManifest.centroid_model: Option` taggea qué modelo produjo el `centroid`. `None` = legacy pre-versioning. - `nouser_core::embed::MODEL_ID = "nouser-pseudo-32d"`. El cluster lo setea en cada Mónada que genera. - `nouser-nous-mock` reusa la misma constante (`use nouser_core::embed::MODEL_ID`); produce vectores idénticos al cluster local, así que reportar el mismo ID es honesto. - `nouser-nous-real` reporta `"real-fastembed-allMiniLML6V2-384d"` (dim distinta, semántica distinta). - `cmd_attract` ahora: - Captura el `model_id` del embedding del target (local o remote). - Filtra Mónadas cuyo `centroid_model` no matchee. - Reporta `embed: ()` y `skipped: N mónadas con centroid_model distinto` cuando descarta. Resultado operativo: cambiar de mock a real (vía `BRAHMAN_BROKER_CONTEXT=prod`) hace que `attract` filtre las Mónadas viejas con cero score en lugar de fingir que las puede comparar. ## 2026-05-08 ### chore: profile.dev slim — target/ ~50% más liviano Cambios en `[profile.dev]` raíz para que builds futuras no desborden disco. Decisiones: - `debug = "line-tables-only"`: stack traces correctos, drop del resto de symbols. Sin pérdida real para nuestro flujo. - `split-debuginfo = "unpacked"`: relink más rápido, debuginfo en archivos aparte. - `codegen-units = 256`: paralelismo + builds incrementales chicas. - Override `[profile.dev.package.X]` para los pesados (gpui, ort, fastembed, tokenizers, image): `opt-level = 1`, `debug = false`. No los debuggeamos línea por línea, no necesitan info pesada. Resultado: binarios ~3× más livianos. ente-zero 125→47 MB; mock-nous ~50→22 MB. ### feat(nouser): dynamic binding — consumer descubre el provider vía broker Cierra el bucle prometido por `priority_contexts`: el cliente ya no hardcodea el socket del provider de embeddings. En su lugar: 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (atajo explícito). 2. Si no, abre `brahman_handshake::client::Client` al `brahman-init`, anuncia un consumer Card mínimo con `flow.input = embed-result:json`, espera 3s por el primer `MatchEvent::Available`, y usa el `producer_service_socket` que viaja en el evento. Esto activa el swap automático mock↔real: - `BRAHMAN_BROKER_CONTEXT=test`: el bias `+1 en test` del mock lo hace ganar; consumer recibe el socket del mock. - `BRAHMAN_BROKER_CONTEXT=prod`: el bias del real lo hace ganar. - Sin contexto: empate alfabético entre los presentes. Validación end-to-end: $ ente-zero & nouser-nous-mock & $ # Sin NOUSER_NOUS_SOCKET: $ nouser attract --remote crates/core archivo.rs embed: remote 🧲 0.9058 ente-brain/src ... (mock log confirma "embed_file path=...") Cambios: - `nouser-core` Cargo.toml: deps directas brahman-handshake + tokio. - `cmd_attract` resuelve el socket por discovery antes de llamar a `embed_via(&path, file)` (mini-runtime tokio current_thread inline). Bug que se descubrió en el camino: la "flakiness" reportada de `cargo test --workspace` era disco lleno (24 GB en `target/`), no condición de carrera. Con `cargo clean` + profile slim, todos los tests pasan deterministas. ### feat(nouser): yahweh widget — `nouser-explorer` panel GPUI Bin GPUI standalone que consulta `brahman-admin` cada 2s y renderea todas las sesiones del Init como cards. Cierra el círculo visual del ecosistema brahman. - Crate nuevo `crates/apps/nouser-explorer` (deps: brahman-admin, brahman-card, gpui). - Ventana 900×640 con header del estado del Init, banner de error cuando no conecta, y lista de cards (una por sesión). - Cada card muestra: kind + label + lifecycle, ULID corto, summary (si data), keywords, lens hint, service_socket si está, y refs (RelationshipKind → target_label). El borde izquierdo coloreado diferencia ente (azul) de data (lavanda). - `cx.spawn(async move |this, cx| { … })` corre el loop de refresh en el GPUI executor; `query_blocking` se usa porque GPUI no provee un runtime tokio. - Nuevo helper en brahman-admin: `client::query_blocking(path)` — versión sync de `query()`, para callers con su propio executor. Uso: $ ente-zero & nouser daemon crates/core & $ cargo run -p nouser-explorer # ventana muestra ~6 cards en vivo, refrescando cada 2s. cargo check --workspace: 0 errores, 0 warnings. ### feat(nouser): persistencia sled write-through del MonadDb `MonadDb` ahora soporta backend dual: - `MonadDb::new()` → memoria pura (default, back-compat). - `MonadDb::open(path)` → sled-backed con cache en memoria. Carga contenido existente al abrir; cada `insert_*` hace write-through (cache + sled). Diseño: - 2 trees sled: `files` y `monads`. - Wire format: serde_json (ergonomía + inspectability con sled-cli; los manifests son chicos, JSON gana sobre postcard aquí). - Reads SIEMPRE desde la cache — sled se consulta sólo al abrir. - `replace_monads()` purga el tree de sled antes de escribir. Bin nouser: nueva env var `NOUSER_DB_PATH`. Si está set, persiste en esa ruta; si no, in-memory: $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core scan: 102 archivos en crates/core, 5 mónadas $ ls /tmp/monads.sled blobs conf $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core # segunda corrida re-escribe la DB con el nuevo scan Tests nuevos en db.rs: - `persistence_roundtrip` — escribe, cierra, reabre, datos están. - `replace_monads_purges_persistent_tree` — replace limpia el tree. 24 tests en nouser-core (era 22, +2). ### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime Antes: cada `spawn(card)` creaba un thread + tokio runtime propio. Para módulos que publican muchas sesiones (nouser daemon con 50+ Mónadas) eso es 50 threads + 50 runtimes. Ahora: **un thread + un runtime tokio current_thread** que hostea N tasks de sidecar. API nueva (aditiva, no rompe `spawn`/`spawn_with_handle`): let pool = SidecarPool::new()?; pool.spawn(card1); pool.spawn(card2); pool.spawn_conscious(card_wit, wit); pool.spawn_with_config(SidecarConfig::new(c).with_wit(w)); // pool drop = todas las sesiones cierran. `run_client` se hace pública para que el pool pueda enqueuar tasks externos al runtime con `handle.spawn(run_client(config))`. `nouser daemon` migrado al pool. Verificación con `ps -L`: $ ps -L -p $(pidof nouser) LWP CMD 28817 nouser # main thread 28819 brahman-sidecar # pool thread (todas las sesiones) Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar cuántas Mónadas se publiquen. ### feat: Crossreferencia — Card.references como grafo del fractal Las Cards ahora declaran sus relaciones con otras Cards. El Engine posee Mónadas; las Mónadas declaran que son poseídas por el Engine. La UI puede cruzar el grafo sin discovery especial. - `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` 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` 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`. 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. ### refactor(nouser): labels de Mónada con 2 componentes del path Resuelve la fricción visual de monorepos donde múltiples Mónadas se llamaban "src". Nueva función `label_from_path` toma los últimos hasta 2 componentes normales del path y los une con `/`. $ nouser scan crates/core [01K..] brahman-admin/src card=5 [01K..] brahman-handshake/src card=6 [01K..] ente-brain/src card=11 [01K..] ente-kernel/src card=4 ... Tests añadidos: `label_from_root_only_one_component`, `label_from_deep_path_takes_last_two`. Tests existentes actualizados con los nuevos labels. ### feat(nouser): Phase D-2 — proveedor Nous real (LLM) detrás de feature flag Cierra el ciclo del módulo Nous: existe un proveedor que produce embeddings reales con un modelo LLM, mientras que `cargo build` sin features sigue siendo liviano (no descarga ni compila ML deps). Crate nuevo: - `crates/modules/nouser/nous-real`: bin con dos modos según feature. - **Sin feature (default)**: stub. Bin compila en ~10s, arranca, sidecarea a brahman-init declarando la Card de real-nous, escucha en el socket Nous, y rechaza toda request con `ErrorResponse { error: "compilado sin la feature embeddings. Rebuild con cargo build -p nouser-nous-real --features embeddings" }`. `cargo build --workspace` sigue siendo limpio. - **Con `--features embeddings`**: pulls `fastembed = "4"`. Ese crate arrastra `ort 2.0.0-rc.9` (ONNX Runtime con binarios descargados por Cargo) + `tokenizers 0.21` + ~30 deps más. Compila en ~50s. Modelo default: `all-MiniLM-L6-v2` (384-d, descargado a `~/.cache/fastembed` la primera vez). - `EmbedText`: pasa el texto al modelo, devuelve vector 384-d. - `EmbedFile`: lee primeros 8KiB con UTF-8 lossy, embed como texto. Para binarios el resultado no es semánticamente útil — caller decide. - `Ping`: devuelve `model_id` y `embed_dim` reales. - Card de real-nous: - label `nouser.nous_real` (distinto del mock para coexistir). - `priority_contexts.prod = { priority_offset: +1 }`. En contexto prod gana sobre el mock; en `test` el mock gana por su propio `+1`. Sin contexto activo, empate alfabético entre ambos. Validación end-to-end con modelo real: $ cargo build -p nouser-nous-real --features embeddings # ~50s $ ente-zero & nouser-nous-real & $ # probe vía python al socket Unix: $ echo '{"kind":"embed_text","payload":{"text":"hello brahman"}}' \ | python3 -c "..." | head model: real-fastembed-allMiniLML6V2-384d elapsed_ms: 8 embed_dim: 384 first 5 values: [0.0034, -0.0036, 0.0078, -0.0218, -0.0162] Tradeoff conocido: las dimensiones del mock (32-d) y real (384-d) son incompatibles. Cambiar de proveedor invalida los centroides cacheados de Mónadas. Documentar como "limpiar DB al cambiar proveedor". Workspace state: - cargo build --workspace sigue limpio sin features (no ML). - cargo build -p nouser-nous-real --features embeddings funciona. - 0 errores, 0 warnings en ambos modos. Pendientes para D-3 / futuro: - Discovery de socket: hoy el consumer hardcodea NOUSER_NOUS_SOCKET. Para que el broker brahman elija real vs mock per-contexto, falta inyectar el socket del provider electo en el MatchEvent o exponer un broker query "dame el socket de la sesión X". - Coexistencia: hoy los dos providers compiten por el mismo socket path por default. Habría que parametrizarlos a sockets distintos cuando coexistan. ### feat(nouser): Phase D — proveedor Nous mock + cliente remoto Cierra el patrón "Nous como módulo aparte intercambiable": el contrato del proveedor de embeddings vive en su crate, el mock determinístico implementa ese contrato sirviéndolo por Unix socket, y `nouser-core` sabe consumirlo remotamente. El switch entre mock y real (futuro) se hará vía priority_contexts en el broker. Crates nuevos: - `crates/modules/nouser/nous`: contrato compartido. Tipos `EmbedRequest`, `RequestKind { EmbedFile, EmbedText, Ping }`, `EmbedFilePayload`, `EmbedTextPayload`, `EmbedResponse`, `PingResponse`, `ErrorResponse`. Wire format: line-delimited JSON por Unix socket, single-shot per conexión. Constants para los nombres de flow (`embed-request`/`embed-result`) y el tipo (`json`). Helper `transport::default_socket_path()` con env var `NOUSER_NOUS_SOCKET`. - `crates/modules/nouser/nous-mock`: bin `nouser-nous-mock`. Sidecarea a brahman-init con Card kind=Ente declarando los flows `embed-request:json`/`embed-result:json` y un `priority_contexts.test = { priority_offset: +1 }` (gana sobre cualquier real-nous en contexto test). Bind del socket Nous, accept loop, despacha por `RequestKind`. EmbedFile usa `nouser_core::embed::embed` (los pseudo-embeddings de Phase C). Modelo: `mock-pseudo-32d`. Cambios: - `nouser-core`: dep nueva `nouser-nous`. Subcomando `attract` ahora acepta `--remote` que abre un socket UnixStream blocking, envía un `EmbedRequest` y lee la response. Imprime `embed: local|remote` para que se vea cuál ruta corrió. Validación end-to-end (un solo terminal, varios procesos): $ ente-zero & $ nouser-nous-mock & $ NOUSER_MIN_FILES=5 nouser daemon crates/core & $ brahman-status Sessions (7): [ente] nouser.nous_mock flows: embed-request, embed-result [ente] brahman.nouser_engine [data] src summary: 6 archivos en crates/core/brahman-handshake/src [data] graph summary: 7 archivos en crates/core/ente-zero/src/graph ... $ nouser attract --remote crates/core embed: remote 🧲 0.9058 src ... Mock log: "embed_file path=crates/modules/nouser/core/src/embed.rs" Bug encontrado y corregido en el camino: - `ContextBias` tenía `#[serde(skip_serializing_if = ...)]` en sus campos. Postcard NO soporta skip-condicional (formato no self-describing): el serializer omitía bytes que el deserializer esperaba, rompiendo la wire de cualquier Card con `priority_contexts` poblada. - Fix: removidos los `skip_serializing_if` de `ContextBias`. JSON pretty ahora emite `{"pin_to": null, "priority_offset": 0}` en lugar de objeto vacío. Trade-off aceptado por compatibilidad de wire. - Test nuevo en brahman-card: `wirecard_postcard_with_priority_contexts` que ejercita el roundtrip completo postcard. Tests acumulados: 75 (card 12 +1 nuevo, broker 15, handshake 9, card-wit 4, admin 0, nouser-card 7, nouser-core 20, nouser-nous 2). cargo check --workspace: 0 errores, 0 warnings. Próximo natural: Phase D-2 — `real-nous` con un modelo ONNX/Llama de text-embedding. La infraestructura ya está lista: declara la misma Card con `priority_contexts.prod = { priority_offset: +1 }` y el swap es transparente para el consumer. ### feat(nouser): Phase C — pseudo-embeddings + atracción por centroide El "imán semántico" matemático del diseño Kairos, sin LLM. Cada archivo se proyecta a un vector 32-d derivado de sus metadatos; cada Mónada calcula su centroide; archivos nuevos se asignan por cosine similarity contra los centroides existentes. Cambios: - nouser-core dep nueva: `blake3` (hash determinista de strings). - `crates/modules/nouser/core/src/embed.rs`: - `EMBED_DIM = 32`. Estructura del vector: - dims 0..8: blake3(extension) → identidad de tipo - dims 8..16: blake3(parent_dir) → identidad de contenedor - dims 16..24: blake3(file_stem) → identidad léxica - dims 24..28: tamaño (log + flags) - dims 28..32: mtime (escala día + cíclicas) - **Tip clave**: bytes del hash se centran a `[-1, 1]` (no `[0, 1]`). Sin centrar, dos vectores hash random tendrían cosine ~0.75 espuria; centrados, expectativa ≈ 0 entre no-relacionados. - APIs: `embed`, `cosine_similarity`, `centroid`, `cohesion`, `attraction_score`, `best_attraction`. `DEFAULT_ATTRACTION_THRESHOLD = 0.7`. - `cluster::by_directory` ahora computa el centroide de cada Mónada (promedio de embeddings de los miembros, L2-normalizado) y lo guarda en `MonadManifest.centroid`. El centroide viaja al brahman-status vía `DataFacet.centroid` → ahora se ven los Vec reales por cada Mónada. - bin nouser nuevo subcomando: `attract `. - Escanea el dir, embeda el archivo objetivo, ranking de afinidad contra todas las Mónadas con centroide. - Marca 🧲 si la mejor supera el umbral, `·` si es la mejor pero debajo, espacio en blanco para el resto. Validación end-to-end: $ nouser attract crates/core crates/modules/nouser/core/src/embed.rs ranking de atracción (cosine similarity): 🧲 0.9058 [01K..] src (11 archivos en crates/core/ente-brain/src) 0.8984 [01K..] src (6 archivos en crates/core/brahman-handshake/src) 0.8918 [01K..] src (5 archivos en crates/core/ente-zero/src) ... $ nouser attract crates/core crates/modules/nouser/core/Cargo.toml ranking: 0.3427 [01K..] graph (7 archivos en crates/core/ente-zero/src/graph) ... (mejor score 0.3427 < umbral 0.7000 — el archivo no se 'pega') Tests: 20 en nouser-core (era 13, +7 de embed). Total acumulado: 73 (card 11, broker 15, handshake codec+tr 2 + integ 7, card-wit 4, admin 0, nouser-card 7, nouser-core 20, ente-card 0). cargo check --workspace: 0 errores, 0 warnings. Próximo: **Phase D** — `nouser-nous`, módulo aparte para LLM real. Mock-nous determinista (basado en estos pseudo-embeddings) en `BRAHMAN_BROKER_CONTEXT=test`; real-nous en `prod`. El switch lo hace el broker via priority_contexts sin tocar nada más. ### feat(nouser): Phase B-2 — daemon que publica Mónadas al Init Cierra la unificación: el `nouser daemon` se sidecarea como Ente y publica cada Mónada como su propia sesión Data. Un solo `brahman-status` muestra procesos y datos en la misma lista, exactamente como buscaba el diseño. Cambios: - `crates/modules/nouser/core/Cargo.toml`: deps nuevas `brahman-card` y `brahman-sidecar`. - `crates/modules/nouser/core/src/bin/nouser.rs`: subcomando `daemon `. - Spawna un sidecar para el "engine" (`brahman.nouser_engine`, kind=Ente) — el ser que produce y administra Mónadas. - Scan + cluster del dir. - Para cada Mónada, llama `monad.to_brahman_card()` y spawnea un sidecar (kind=Data). Cada Mónada es una sesión brahman propia con su ULID estable. - Park del thread principal: los sidecars siguen pingueando. Validación end-to-end: $ ente-zero & $ NOUSER_MIN_FILES=5 nouser daemon crates/core & $ brahman-status Sessions (6): [ente] ... brahman.nouser_engine lifecycle=Daemon [data] ... src summary: 5 archivos en crates/core/brahman-admin/src members: 5 (dispersion=0.00) lens hint: code [data] ... src summary: 11 archivos en crates/core/ente-brain/src ... [data] ... graph summary: 7 archivos en crates/core/ente-zero/src/graph El protocolo de presentación es uno solo: la Card. La función — anunciar identidad, exponer metadata, ser descubierto — es idéntica para procesos vivos y agrupaciones de datos. La UI lo ve como una lista uniforme. Costo conocido: cada Mónada consume un thread + tokio runtime current_thread (legacy del sidecar API). Para muchas Mónadas (>50) conviene consolidar en un único runtime con N tasks. Defer a Phase B-3. Pendientes propuestos: - **B-3**: consolidar todos los sidecars en un único runtime tokio para no spawnear N threads. - **C**: pseudo-embeddings + atracción por centroide. - **D**: módulo `nouser-nous` para LLM, swappable por priority_contexts. - **Polish**: labels con 2-3 componentes del path. - **Crossreferencia**: que un Ente pueda anunciar "estoy procesando la Mónada X" y la Mónada anuncie "Ente Y me está procesando". cargo check --workspace: 0 errores, 0 warnings. ### feat: Phase B-1 — unificación ontológica de Cards (Ente ↔ Data) La Card es **el** protocolo de presentación del ecosistema, no sólo de los procesos. Una Mónada Nouser y un Ente Brahman son ambos "entidades que se presentan"; el consumidor (UI, broker, admin) discrimina por `kind` cuando importa, pero todos hablan el mismo idioma. Cambios: - `brahman-card`: - `CardKind { Ente (default), Data }`. Conserva back-compat: Cards existentes son `Ente` por default. - `DataFacet { summary, keywords, centroid, member_count, dispersion, presentation_hint }`. Liviano para el wire — listas grandes (members, embeddings completos) se consultan al daemon dueño bajo demanda. - `Card.kind` y `Card.data: Option` agregados. WireCard espeja, conversiones `From` propagan. - Default impl actualizado. - `brahman-broker::BrokeredCard`: propaga `kind` y `data` desde la Card registrada. No afecta el matching (sigue siendo por TypeRef + priority + pin_to); permite a observadores discriminar sin re-query. - `nouser-card`: depende ahora de `brahman-card`. Nuevo método `MonadManifest::to_brahman_card()` que proyecta: - id, label, lineage → directos. - payload Virtual, supervision Delegate, lifecycle Daemon (placeholder semántico — la Mónada no se ejecuta). - kind = Data. - data = Some(DataFacet) con summary, keywords, centroide, member_count, entropy → dispersion, y un `presentation_hint` derivado del `Lens` (`Code` → `"code"`, `Gallery` → `"gallery"`, etc.). - Test nuevo: `projects_to_brahman_card`. - `brahman-status`: cada sesión muestra ahora `[ente]` o `[data]` como prefijo. Para sesiones `data`, render adicional con summary, members + dispersion, keywords y lens hint. Resultado: la UI (yahweh, brahman-status, futuro explorer) ve una sola lista uniforme. No tiene que saber si está mirando un proceso o un cúmulo de datos — sólo lee el Card y se adapta por `kind`. Tests acumulados: 59 (card 11, broker 15, handshake codec+transport 2 + integ 7, card-wit 4, admin 0, nouser-card 7, nouser-core 13). cargo check --workspace: 0 errores, 0 warnings. Próximo: **Phase B-2** — bin `nouser daemon ` que sidecarea cada Mónada como una sesión brahman, publicándola al broker. Brahman-status las verá junto a los entes. ### feat(nouser): Phase A — mecanismo determinista de Mónadas Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como "imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los casos sin tocar IA — sólo metadatos y heurísticas. Crates nuevos: - `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de Presentación de una Mónada — espejo conceptual de `brahman::Card` pero para datos, no para procesos runtime). Campos: id (Ulid), label, summary, centroid (vacío en Phase A), keywords, cardinality, entropy [0,1], dominant_lens, pins, members, timestamps, extensions (forward-compat). 6 tests de validación + JSON roundtrip. - `crates/modules/nouser/core`: pipeline determinista. - `scanner`: walkdir → `Vec` con metadatos (path, size, mtime, extension). Skipea hidden por default. Configurable max depth y follow_links. - `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos para promover a Mónada (configurable). Calcula keywords (top-N extensiones por frecuencia + alfabético), elige `Lens` dominante (Code/Gallery/Markdown/Database/Grid) según extensión más frecuente, computa entropía de Shannon normalizada [0,1]. - `db`: `MonadDb` en memoria con índices BTreeMap files/monads y `resolve_members(monad_id)` que filtra IDs huérfanos. Phase B traerá persistencia. - bin `nouser`: subcomandos `scan `, `show `, `json `. Env var `NOUSER_MIN_FILES` para tunear el threshold. - 13 tests (4 scanner + 6 cluster + 3 db). Demo end-to-end: $ nouser scan crates scan: 255 archivos en crates, 19 mónadas (min_files=3) [01KR4C13] src card=12 ent=0.00 lens=Code keywords: rs [01KR4C13] tests card=14 ent=0.00 lens=Code keywords: rs [01KR4C13] fixtures card=5 ent=0.00 lens=Grid keywords: rhai ... $ nouser show crates 01KR4C Monad 01KR4C1370DVF6NMTW6SECNXAF label: src summary: 4 archivos en crates/modules/nouser/core/src (ext: rs) cardinality: 4 entropy: 0.0000 lens: Code members (4): 4132 bytes crates/modules/nouser/core/src/db.rs ... Pendientes para próximas fases (anotados, no urgentes): - **Phase B**: bin `nouser daemon` que sidecarea a brahman-init declarando flows (`scan-request:json` → `monad-update:json`). - **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size a 32-d) + atracción por centroide via cosine similarity. Implementa el "imán" sin LLM. - **Phase D**: módulo `nouser-nous` aparte para el LLM real (Llama/ONNX). En `priority_contexts.test` el Init pinea a `mock-nous` (embeddings determinísticos); en `prod` a `real-nous`. - **Polish**: labels de Mónada incluir 2-3 componentes del path para desambiguar `src/` repetidos en monorepo. Workspace: 0 errores, 0 warnings. Tests acumulados: 58 (card 11, broker 15, handshake codec+transport 2 + integ 7, card-wit 4, admin 0, nouser-card 6, nouser-core 13). ### feat(broker): priority contexts — biases per-contexto operativo - `brahman-card::ContextBias { pin_to: Option, priority_offset: i8 }` declara un override per-contexto. - `Card.priority_contexts: BTreeMap` y mismo en `WireCard` (cruza el wire). Las conversiones `From` lo propagan. - `BrokerConfig.current_context: Option`. 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: ` 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` 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` recursivo). Conversiones `From` y `From` 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`. 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)`. - `brahman-broker::Broker::register` ahora toma `Option` 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` (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>>>) 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` 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>>`. - `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, 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).