# Changelog — protocol/ Contratos canónicos + routing entre módulos. Antes: `core/brahman-*` + `shared/brahman-*`. ### feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock Crate nuevo `crates/protocol/brahman-auth`: la base del DM/greeter de carmen (mirada). Contrato `Authenticator` agnóstico del backend: `authenticate(usuario, secreto) -> UserInfo`, donde `UserInfo` lleva `uid/gid/home/shell` — lo que el compositor necesita para arrancar la sesión. - **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/`, por defecto `carmen`), el mismo subsistema de `login`/`sudo`. Un handle PAM nuevo por intento; `authenticate()` cubre credenciales + estado de la cuenta. `map_pam_error` traduce los `PamReturnCode` a la taxonomía gruesa de `AuthError`. - **`MockAuthenticator`** — credenciales fijas en memoria, para tests y para iterar el greeter en cajas sin PAM. - `AuthError` deliberadamente grueso: `BadCredentials` (reintentar) vs `AccountUnavailable` (cuenta vetada); usuario inexistente y contraseña errada dan el **mismo** error (no filtra existencia de cuentas). - `resolve_user` vía `getpwnam` (nix). `UserInfo::synthetic` para dev. - `data/carmen` — archivo de servicio PAM. Ejemplo `auth-probe` para verificar PAM en una máquina real. - 11 tests; el camino PAM real se ejercita (falla limpio sin servicio). ### feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o future-me retomando el repo) puede levantar el escenario completo con un comando. Crate nuevo `crates/apps/brahman-demo/` con 3 binarios: - **`brahman-demo-broker`**: standalone `Server::bind` con un Broker configurado, escucha en el socket default. Reemplaza a `ente-zero` para fines de demo (ente-zero pesa kernel surface + child subreaper + bus + brain + audit; el demo no lo necesita). - **`brahman-demo-producer`**: registra una Card con `flow.output[demo-stream:json]` y queda pingueando. - **`brahman-demo-consumer`**: registra una Card con `flow.input[demo-feed:json]` (mismo type → matchea con el producer) y queda escuchando `MatchEvent`s. 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})`**: 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 `MatchEvent`s 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` mantiene el estado del último snapshot. La key es `(consumer.session, consumer.flow, producer.session, producer.flow)`. - `Explorer.timeline: VecDeque` 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 `MatchEvent`s 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`. 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: ```nickel # 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): 1. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`). 2. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`. 3. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts; los 3 schemas .k de nakui modules pasan a .ncl. 4. card.k eliminado (es REFERENCE ONLY documentado). ### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica **Pivote arquitectónico** decidido en charla: Brahman maneja varios formatos legítimos de "Card" (cada formato vive en su crate origen y conserva su shape público), y un **único brazo** los lee, completa desde templates si vienen simplificados, y los proyecta a UNA sola estructura interna canónica que consumen UI runtime / storage / DHT / wire. Agregar un formato nuevo = agregar un reader, sin tocar consumers. **V1 en este commit**: estructura canónica + readers para los 3 formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado para próximo commit). Crate nuevo `crates/core/brahman-cards/`: - **`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`. 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): 1. Reader Nickel + template merge. 2. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`). 3. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/` (esperando hasta que el brazo + canónico estén estables). 4. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel contracts; los 3 schemas .k de nakui modules pasan a .ncl. 5. card.k eliminado (es REFERENCE ONLY documentado). ### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar la keypair libp2p de un nodo cambiaba su `peer_id`, lo que invalidaba todas las allowlists/denylists remotas que lo referenciaban. Imposible rotar sin coordinar con todos los pares. Solución: separar **identity master** (Ed25519 persistente forever, identifica al nodo como entidad lógica) de **session libp2p** (Ed25519 efímera, rotable). El master firma certs de session con expiración. La política de admisión se evalúa contra el `master_peer_id` del cert — el session peer_id puede cambiar libremente sin tocar las allowlists. API nueva en `brahman_handshake::identity`: - `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` 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` 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>>>`. Default `None` para preservar callers existentes. - Nuevo método `attach_to_net(net: Arc)`: - 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>, 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(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(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(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. ### 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).