# Changelog — protocol/

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

### feat(brahman-auth): SessionTicket — el tiquet greeter→compositor

`brahman_auth::SessionTicket { user: UserInfo, session: String }`: lo
que el greeter le entrega al compositor tras un login exitoso.
`to_line`/`from_line` lo serializan a una **línea única** (campos por
tabulador, prefijo versionado `MIRADA-SESSION-TICKET-v1`) — el greeter
la imprime a stdout y el compositor escanea sus líneas buscando el
prefijo. 5 tests de round-trip y rechazo de líneas malformadas.

### feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock

Crate nuevo `crates/protocol/brahman-auth`: la base del DM/greeter de
carmen (mirada). Contrato `Authenticator` agnóstico del backend:
`authenticate(usuario, secreto) -> UserInfo`, donde `UserInfo` lleva
`uid/gid/home/shell` — lo que el compositor necesita para arrancar la
sesión.

- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`,
  por defecto `carmen`), el mismo subsistema de `login`/`sudo`. Un handle
  PAM nuevo por intento; `authenticate()` cubre credenciales + estado de
  la cuenta. `map_pam_error` traduce los `PamReturnCode` a la taxonomía
  gruesa de `AuthError`.
- **`MockAuthenticator`** — credenciales fijas en memoria, para tests y
  para iterar el greeter en cajas sin PAM.
- `AuthError` deliberadamente grueso: `BadCredentials` (reintentar) vs
  `AccountUnavailable` (cuenta vetada); usuario inexistente y contraseña
  errada dan el **mismo** error (no filtra existencia de cuentas).
- `resolve_user` vía `getpwnam` (nix). `UserInfo::synthetic` para dev.
- `data/carmen` — archivo de servicio PAM. Ejemplo `auth-probe` para
  verificar PAM en una máquina real.
- 11 tests; el camino PAM real se ejercita (falla limpio sin servicio).

### feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers
Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o
future-me retomando el repo) puede levantar el escenario completo
con un comando.

Crate nuevo `crates/apps/brahman-demo/` con 3 binarios:
- **`brahman-demo-broker`**: standalone `Server::bind` con un Broker
  configurado, escucha en el socket default. Reemplaza a
  `ente-zero` para fines de demo (ente-zero pesa kernel surface +
  child subreaper + bus + brain + audit; el demo no lo necesita).
- **`brahman-demo-producer`**: registra una Card con `flow.output[demo-stream:json]`
  y queda pingueando.
- **`brahman-demo-consumer`**: registra una Card con `flow.input[demo-feed:json]`
  (mismo type → matchea con el producer) y queda escuchando
  `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<brahman_broker::Match>})`**:
  respuesta. Cada `Match` ya es serializable y lleva `consumer`,
  `consumer_label`, `producer`, `producer_label`, `ty`, `via`, `pinned`.

`brahman-handshake/server.rs`:
- `run_post_handshake` ahora pasa también `broker_for_match: Option<&SharedBroker>`
  al `handle_inbound_frame`.
- Si el server tiene broker configurado, `ListMatches` responde con
  `broker.all_matches()`. Si no (server sin broker), responde
  `MatchList { matches: vec![] }` — refleja "no hay matching activo",
  no es un error.

`brahman-handshake/client.rs`: `Client::list_matches()` análogo a
`list_sessions()`, drena `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<MatchKey>` mantiene el estado
  del último snapshot. La key es `(consumer.session, consumer.flow,
  producer.session, producer.flow)`.
- `Explorer.timeline: VecDeque<TimelineEntry>` con cap `TIMELINE_CAP=50`.
- Función pura `diff_matches(last_keys, list) -> (entries, new_keys)`:
  emite `Available` para keys nuevas y `Lost` para keys desaparecidas.
  Primer tick (last_keys vacío) marca todo como Available — cubre
  el boot sin que la UI quede vacía.
- Render: `stat_card` "Timeline de matches" con count + 20 entries
  formateadas como `HH:MM:SS {+/-} consumer.flow ← producer.flow [via]`.
  Más reciente arriba.

Tests broker-explorer: 5 totales.
- `diff_matches_first_snapshot_marks_everything_available`
- `diff_matches_emits_lost_when_match_disappears`
- `diff_matches_no_change_emits_nothing`
- `pending_is_default_state_at_boot` (existente)
- `poll_and_probe_constants_are_sane` (existente)

Decisión: timeline polled (cada `POLL_INTERVAL=5s`), no push.
Razón: los `MatchEvent` push del broker son consumer-céntricos
(cada session sólo ve sus propios matches). Para "system-wide
timeline" haría falta una API broker-level "subscribe a todos" —
mucho más scope. Polling cada 5s es suficiente para observabilidad.

### feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer
Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier
módulo conectado puede preguntar al broker la lista de sesiones
activas y mostrar labels + flows in/out por cada una.

`brahman-handshake/messages.rs`:
- **`Frame::ListSessions(ListSessions { session })`**: request del
  cliente (server valida que `session` coincida con la sesión vigente,
  mismo patrón que Ping/Farewell).
- **`Frame::SessionList(SessionList { entries })`**: respuesta.
  Cada `SessionEntry` lleva: `session`, `label`, `schema_version`,
  `outputs` (nombres de flow.output), `inputs` (nombres de
  flow.input), `conscious` (`true` si la Card vino con WIT).

`brahman-handshake/server.rs`:
- `run_post_handshake` ahora pasa `SessionRegistry` a
  `handle_inbound_frame` (necesario para consultar el snapshot de
  sesiones en respuesta a `ListSessions`).
- Helper `build_session_list(sessions)` que toma el snapshot bajo
  el lock, lo proyecta a `SessionList`, y suelta el lock antes de
  escribir el frame al wire.
- Validación `session_id` mismatched → `HandshakeError::Unauthorized`.

`brahman-handshake/client.rs`:
- `Client::list_sessions()` async: envía el request, drena
  `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<Value, NickelEvalError>`. Errores tipados:
  `Io`, `Eval`, `Export`, `JsonReparse` — el mensaje de Nickel se
  formatea como texto plano (sin ANSI) para que sea legible en
  logs y toasts.
- **`load_card_with` añade `"ncl"`**: lee archivo → eval Nickel →
  exporta a JSON → parsea de vuelta a Value → dispatch a los
  readers JSON. Pipeline simétrico a `"json"`.
- **`CardLoadError::Nickel(NickelEvalError)`**: el error de
  Nickel se propaga limpio al error público del brazo.
- **Resolución de imports**:
  - El parent dir del input se agrega como import path → `import
    "./template.ncl"` resuelve sin config.
  - El env `BRAHMAN_CARDS_TEMPLATES_DIR` (constante exportada
    `BRAHMAN_CARDS_TEMPLATES_ENV`) agrega un registry global →
    `import "ui_module_minimal.ncl"` desde cualquier ubicación.
  - No hay magic resolución por kind. El autor del Card decide
    qué template importa.

**Convención obligatoria de templates** (documentada en
`nickel_eval.rs`): las fields que el usuario va a sobrescribir
deben marcarse `| default` (o `| optional`). Sin ese marker
Nickel rechaza el merge de strings/numbers no-iguales con la
misma prioridad. Patrón canónico:

```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<Card>`. El dispatcher prueba en orden y
  delega al primero que matchee.
- **3 readers concretos** (en `readers.rs`):
  - `EnteJsonReader` — heurística: `payload` Y `supervision`
    presentes simultáneamente.
  - `MonadJsonReader` — heurística: `members` Y `cardinality`.
  - `UiModuleJsonReader` — heurística: `entities` Y `views` Y
    `menu`. El más específico, va primero en `default_readers()`.
- **Entry points**:
  - `load_card(path)` — abre archivo, dispatcha por extensión, dentro
    de JSON prueba los readers default.
  - `load_card_with(path, readers)` — variante con set custom para
    apps que quieren restringir formatos.
- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`,
  `NoMatchingReader`, `ReaderFailed { reader, message }`,
  `UnsupportedExtension { ext, supported }`.

13 tests integration:
- 3 detection tests (cada reader matchea sólo su shape, rechaza los
  otros 2 + non-object).
- 3 dispatch+projection tests (cada formato JSON cargado produce el
  variant esperado con campos del wrapper bien derivados).
- 2 negative cases (NoMatchingReader, non-object input).
- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples
  readers — defiende el contrato de orden documentado).
- 1 e2e desde disco con `load_card_with`.
- 1 unsupported extension.
- 1 custom reader set (restringir a sólo Ente).
- 1 documented invariant (extensions vacío en V1; si cambia, este
  test se rompe como signal).

13/13 verdes. Workspace build verde tras agregar el crate al
`members[]` del workspace Cargo.toml.

**Lo que NO hace V1** (explícito):
- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda
  aislada para no inflar este commit.
- No define templates — los templates Nickel se diseñan junto al
  reader Nickel (necesitan `merge` nativo de Nickel para fusionar
  override + base).
- No migra consumers. `nakui-ui` sigue cargando `module.json` con
  `nakui_ui_schema::load_modules_from_dir` directo. La migración a
  `brahman_cards::load_card` viene cuando V1 + Nickel + templates
  estén estables.
- No mueve los `extensions` del input a `Card.extensions` — los crates
  origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`).
  Documentado como decisión consciente.

**Pendientes para próximos commits** (orden):
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<SessionCert>` agregado (default None,
  back-compat).
- `Client::connect_with_stream_signed_with_cert(stream, card, wit,
  session_kp, cert)` — variante que adjunta el cert.
- `network::connect_libp2p_with_cert(net, peer, card, wit,
  session_kp, cert)` — paralelo a `connect_libp2p`.

Server (`do_handshake`):
- Nuevo paso ANTES del policy gate: si `Hello.identity_cert.is_some()`,
  se verifica con `verify_against_session(&hello.signature.public_key)`.
  El `logical_peer` que se evalúa contra la policy es el
  `master_peer_id` derivado, NO el session peer_id.
- Sin cert (path Fase 3): `logical_peer = expected_peer` (compat).
- Si el cert es inválido (firma rota, expirado, session mismatch),
  rechazo con `Unauthorized` antes de evaluar policy.
- Migración gradual: clientes sin cert siguen funcionando contra
  servers con policy basada en session peer_ids.

Canonicalización del payload firmado:
```
[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms]
```
`SESSION_CERT_VERSION = 1` documenta el esquema; cualquier cambio
fuerza bump (clientes viejos no validan certs nuevos).

Sobre el swarm-level deny:
- El `block_list` del swarm sigue operando con session peer_ids
  (Noise sólo conoce eso). Si la operatoria lista master_peer_ids
  en deny, el handshake-level gate los para; el swarm-level no.
  El operador elige granularity: listar masters = robust a
  rotaciones; listar sessions = rechazo más temprano.

Tests: 8 unit en `identity::tests`:
- `issue_and_verify_cert` — roundtrip básico, peer_ids derivados.
- `verify_against_session_admits_matching` y
  `_rejects_mismatch` — el cert vincula 1 sola session pubkey.
- `cert_with_zero_ttl_is_expired` — expiración chequeada con tiempo
  real.
- `tampered_signature_rejected` y `tampered_expires_at_rejected` —
  cualquier mutación del cert post-firma falla.
- `unknown_version_rejected` — schema versionado defensivamente.
- `rotated_session_with_same_master_yields_same_master_peer_id` —
  la propiedad fundamental: rotar session NO cambia master_peer_id.

Plus 1 E2E definitivo en `network_libp2p.rs`:
`identity_cert_allows_session_rotation_without_policy_change`.
- A configura `policy = allowlist[B.master_peer_id]` (master, no
  session).
- B se conecta con session1 + cert(master, session1) → admitido.
  Sesión registrada, farewell limpio.
- B "rota": genera session2 ≠ session1, mismo master, emite cert2.
- B se conecta con session2 + cert2 → admitido también, **sin que
  A toque su allowlist**.
- Sanity: una session sin cert (cuyo session_peer NO está en allow)
  es rechazada.

40 tests verdes en brahman-handshake + brahman-net (24 unit
incluyendo identity + 7 handshake + 3 discovery + 6 libp2p
incluyendo rotation E2E). Ningún regreso.

Wire en Arje queda como follow-up: ente-zero hoy es server-only y
no necesita identity (su keypair libp2p ya es estable vía
keypair_store). Cuando algún módulo de Arje haga conexiones
salientes con cert, se cargará la identity master separada de la
session vía nueva env `BRAHMAN_IDENTITY_PATH`. La API ya está
lista.

### feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p
Optimización de seguridad: la denylist ya no espera al handshake
brahman para rechazar — ahora se proyecta al `block_list` behaviour
del swarm libp2p. Conexiones desde peers baneados son rechazadas
**antes del Noise handshake**, ahorrando el round-trip TCP+Noise
por cada intento denegado.

Wire de bajo nivel (`brahman-net`):
- Nuevo behaviour `block_list: allow_block_list::Behaviour<BlockedPeers>`
  añadido al `BrahmanBehaviour` derivado. Vive junto a `stream`,
  `kad`, `identify`. Default vacío al construir.
- Nuevos comandos `BlockPeer(PeerId)` y `UnblockPeer(PeerId)` en el
  enum interno + handlers que llaman
  `swarm.behaviour_mut().block_list.{block_peer,unblock_peer}`.
- API pública: `BrahmanNet::block_peer(peer)` y
  `BrahmanNet::unblock_peer(peer)`. Idempotentes.
- Dep nueva: `libp2p-allow-block-list = "0.6"` (sub-crate, no es
  feature de `libp2p` en 0.56).

Wire en la política (`brahman_handshake::peer_policy`):
- `PeerPolicy` gana campo opcional `net: Arc<RwLock<Option<Arc<BrahmanNet>>>>`.
  Default `None` para preservar callers existentes.
- Nuevo método `attach_to_net(net: Arc<BrahmanNet>)`:
  - Sincronización inicial: itera la deny actual y llama
    `net.block_peer(p)` por cada uno.
  - Guarda el net para diff-sync en cada `reload`.
- `reload()` extendido: snapshot de `prev_deny` ANTES de mutar el
  inner. Tras la mutación, llama `sync_deny_to_swarm(prev, new)`
  que aplica `block_peer` por cada added y `unblock_peer` por cada
  removed.
- Atomicidad preservada: si un archivo falla al parsear, el sync
  no ocurre y la versión anterior persiste tanto en la policy
  como en el block_list del swarm.

Wire en Arje (`ente-zero`):
- Tras setup_brahman_net + setup_brahman_policy, si AMBOS están
  presentes se llama `policy.attach_to_net(net.clone())` con un log
  informativo. Sin policy o sin net, no hay attach (modo abierto
  o solo gate-level deny).

Tests: 1 nuevo E2E en `network_libp2p.rs`:
`swarm_level_deny_blocks_before_noise`. A configura policy con
deny de un peer + attach_to_net. Cliente baneado intenta
`connect_libp2p`; en lugar del `HandshakeError::Unauthorized` que
recibíamos antes (que requería completar Noise primero), ahora
falla con error de transporte/stream (o timeout, según timing) —
el dial nunca completa porque el swarm rechaza la conexión.

5 tests verdes en `network_libp2p.rs` (roundtrip, mismatched signing,
allowlist, denylist handshake-level, denylist swarm-level). 31 tests
totales en brahman-handshake + brahman-net. Sin regresión en
ente-zero.

Trade-offs:
- **Más eficiente** contra DoS: un atacante que prueba miles de
  peer_ids no consume CPU del Noise handshake.
- **Misma fuente de verdad**: la denylist sigue viviendo en
  `PeerPolicy` (un solo archivo, hot-reloadable). El swarm es un
  cache derivado que se actualiza vía diff. No hay drift posible —
  cada reload re-sincroniza atómicamente.
- **El handshake-level gate sigue activo** como segunda línea: si
  por alguna razón un peer baneado pasa el block_list (race entre
  reload y nueva conexión, o bug del crate), el handshake brahman
  igual lo rechaza con `Unauthorized`. Defensa en profundidad.

Pendientes futuros del changelog:
- Rotación de keypair sin perder peer_id (multi-key identity).

### feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers
Consolida `PeerAllowlist` + nueva `PeerDenylist` en un único
`PeerPolicy` con allow + deny + hot reload vía `notify`. Cubre los
dos pendientes documentados en el commit anterior y simplifica la
API hacia un sólo punto de entrada.

API consolidada en `brahman_handshake::peer_policy`:
- `PeerPolicy::open()` — todo permitido (default).
- `PeerPolicy::from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>)`
  — política inline para tests.
- `PeerPolicy::from_files(allow_path?, deny_path?)` — carga ambos
  archivos opcionales.
- `PeerPolicy::evaluate(peer) -> Decision` — `Admit |
  DeniedByDenylist | NotInAllowlist`. Decision lleva su `reason()`
  para logging consistente.
- `PeerPolicy::reload()` — recarga atómica desde los paths
  asociados. **Si un archivo falla, conserva la versión anterior**
  (un typo no debe tirar al Init en modo inseguro).
- `PeerPolicy::spawn_watcher() -> JoinHandle` — vigila los
  archivos vía `notify`, debounce 250ms (coalesce de los varios
  eventos típicos de un save), recarga atómica al detectar cambio.

Orden de evaluación (deny-first):
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<PeerPolicy>`
  (breaking rename, scope local al monorepo). Gate en `do_handshake`
  llama `policy.evaluate(&peer)` y usa `decision.reason()` para el
  mensaje de error tipado.

Wire en Arje (`ente-zero`):
- Nueva env `BRAHMAN_PEER_DENYLIST` complementa
  `BRAHMAN_PEER_ALLOWLIST`. Cualquiera (o ambas) activa la política.
- `setup_brahman_policy()` carga + arranca watcher. Devuelve
  `(policy, JoinHandle)`; el handle se guarda en main para que el
  thread no se aborte.
- Failure modes degradan a "modo abierto" (sin política) con log,
  preservando la doctrina PID 1.

Activación end-to-end con todas las capas activas:
```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<PeerAllowlist>`. `None` = modo
  abierto (compat con todo lo anterior). `Some` = sólo los listados.
- Gate en `do_handshake` ANTES de la verificación de firma — la
  comparación O(log n) en BTreeSet es más barata que crypto, así
  que rechazamos peers inválidos antes de gastar ciclos. Se devuelve
  `HandshakeError::Unauthorized("peer X no está en la allowlist")`.

Wire en Arje (`ente-zero`):
- Nueva env var `BRAHMAN_PEER_ALLOWLIST` apuntando a un archivo.
- `setup_brahman_allowlist()` carga al startup; degrada a `None`
  (modo abierto) si el archivo falla, consistente con la doctrina
  PID 1 de no romper por subsistemas opcionales.

Ejemplo de archivo de allowlist:
```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<HelloSignature>` (nuevo, default None).
- `HelloSignature { public_key: Vec<u8>, signature: Vec<u8> }` —
  public_key en formato canónico libp2p (`encode_protobuf`), firma
  Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option<WitInterface>)`
  serializado postcard.
- `SIGNATURE_VERSION = 1` documenta el esquema del payload firmado;
  bump al cambiar.

Nuevo módulo `brahman_handshake::signature`:
- `sign_hello(keypair, card, wit) -> HelloSignature`.
- `verify_hello(sig, card, wit, expected_peer) -> Result<(), SignatureError>`.
- `SignatureError` tipado (`DecodeKey`, `EncodePayload`, `Invalid`,
  `PeerMismatch`, `Missing`, `Unexpected`).

Server:
- `Session<S>` gana `expected_peer: Option<PeerId>`.
- `Server::session_from_libp2p_stream(stream, peer)` (nuevo)
  construye Session con `expected_peer = Some(peer)`.
  `session_from_stream` (Unix/in-memory) sigue con `None`.
- `do_handshake` exige firma + verifica peer match cuando
  `expected_peer.is_some()`. Si no, verifica firma presente por
  consistencia interna pero no exige que esté.
- `network::run_libp2p_accept_loop` ahora usa
  `session_from_libp2p_stream(stream.compat(), peer)` para propagar
  la identidad libp2p al gate de trust.

Client:
- `Client::connect_with_stream_signed(stream, card, wit, &Keypair)`
  (nuevo) firma el Hello antes de mandarlo.
- `Client::connect_with_stream` sigue existiendo sin firma (path
  Unix / tests).
- `Client::connect`/`connect_with` (Unix) no cambian — siguen sin
  firma porque SO_PEERCRED autentica.
- `network::connect_libp2p(net, peer, card, wit, keypair)`
  **breaking change**: gana parámetro `keypair: &Keypair`.

BrahmanNet:
- Almacena la `Keypair` en `Arc<Keypair>` (libp2p Keypair no es
  Clone; el truco es duplicar el `ed25519::Keypair` interno que sí
  es Clone, una copia para Noise/swarm y otra para signing).
- `BrahmanNet::keypair() -> Arc<Keypair>` accessor para que callers
  puedan firmar con la misma identidad libp2p del nodo sin tener
  que mantener la keypair por separado.
- `with_keypair` rechaza keypairs no-Ed25519 (RSA/ECDSA/Secp256k1
  vendrían a futuro si se necesitan).

Tests:
- 4 unit en `signature::tests`: roundtrip propio, peer mismatch,
  card tampered, signature flipped.
- 1 E2E nuevo en `tests/network_libp2p.rs`:
  `libp2p_handshake_rejects_mismatched_signing_key` — el cliente
  intenta firmar con keypair distinta a la del net; server rechaza.
- E2E positivo (`libp2p_handshake_roundtrip`) ahora pasa la keypair
  del client_net y debe verificar OK.
- Discovery + handshake legacy + signature: 90+ tests verdes en
  brahman-handshake/brahman-net/brahman-card/minga-p2p.

Lo que esto cierra:
- Brahman-net es una malla públicamente dial-able **con
  autenticación criptográfica end-to-end**: Noise para el transport,
  Ed25519 para las Cards.
- La cadena completa de discovery + connect + trust funciona
  cross-machine sin paths hardcodeados ni confianza implícita.
- El plan original ("el encuentro entre Entes no se restringe a
  local, la ejecución remota está pensada desde el principio")
  está implementado y testeado.

Pendientes (futuro, no de hoy):
- `stop_providing` al cleanup de sesión (records DHT viven hasta
  TTL ~24h).
- Wire de Arje (`ente-zero`) para arrancar opcionalmente con
  `BrahmanNet` configurado y `ServerConfig.net = Some(...)`.
- Allowlist/Denylist de peers (hoy cualquier peer Ed25519-válido
  pasa el trust gate; producción podría querer un PKI explícito).
- Persistencia de la keypair de identidad del nodo entre reboots.

### feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type
Tercer paso del plan "el encuentro entre Entes no se restringe a
local". Cuando un Init local acepta una sesión cuya Card declara
outputs, anuncia al DHT (Kademlia, vía `brahman-net`) que él provee
esos flow types. Cualquier nodo conectado al mismo DHT puede
consultar y obtener la lista de `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<PeerId>`:
  query DHT por la key derivada. Lista vacía si nadie anuncia o si
  la query no resuelve dentro del timeout interno de Kad.

Wire en el server:
- `ServerConfig` gana `pub net: Option<Arc<BrahmanNet>>`. Si está set,
  cada Card registrada con outputs se anuncia automáticamente al DHT
  desde `register_session`. `None` = server "ciego al DHT" (correcto
  cuando no hay conectividad o el operador no quiere exponer).
- `ServerConfig` ahora tiene `Debug` manual (BrahmanNet no implementa
  Debug; loggeamos sólo presencia/ausencia).

Canonicalización del TypeRef:
- `Primitive { name }` → `prim:{name}`
- `Wit { package, interface, name }` → `wit:{package}#{interface_or_empty}#{name}`

Tests: 2 nuevos en `tests/network_discovery.rs`:
- `dht_discovery_finds_remote_provider`: dos nodos, A registra Card
  con `flow.output = monad-list:json`, B dial-ea a A y descubre el
  `peer_id` de A vía `find_remote_providers`. Asserts contains.
- `dht_discovery_negative_unknown_flow`: B busca un flow que nadie
  anunció, devuelve lista vacía sin colgarse.

Lo que esto desbloquea:
- Un `nouser daemon` corriendo en máquina A puede ser descubierto por
  un `nouser-explorer` en máquina B sin conocimiento previo del peer
  — sólo necesitan compartir DHT (vía bootstrap inicial).
- La cadena completa "explorer → daemon → llm-provider" puede cruzar
  máquinas, no sólo procesos.

Lo que queda para Fase 3 (trust):
- Cards remotas se aceptan hoy sin verificación. Para producción se
  necesita firma Ed25519 sobre la Card y verificación antes de
  aceptar el Hello remoto. Local sigue confiando en SO_PEERCRED.
- Stop-providing al cleanup de sesión (hoy records DHT viven hasta
  TTL ~24h aunque la sesión cierre).

### feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p
Segundo paso del plan "el encuentro entre Entes no se restringe a
local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
MatchEvent / Farewell, frames postcard length-prefixed) ahora también
viaja sobre streams libp2p de la malla `brahman-net` — el mismo Init
acepta sesiones por Unix socket Y por libp2p indistintamente, y un
consumer remoto puede dial-ar al multiaddr y completar handshake.

Cambios:
- **`Session<S>` y `Client<S>` genéricos**: ambos dejan de estar atados
  a `UnixStream` y pasan a ser genéricos sobre `S: AsyncRead +
  AsyncWrite + Unpin + Send + 'static`. El path Unix queda como
  `Client = Client<UnixStream>` (default genérico). Constructores
  nuevos: `Server::session_from_stream(stream)`,
  `Client::connect_with_stream(stream, card, wit)`.
- **Refactor del post-handshake con split**: `tokio::select!` sobre
  `&mut self.stream` requería `S: Sync` indirectamente, y
  `libp2p::Stream` no es Sync. Reemplazado por
  `tokio::io::split(stream)` → reader loop principal + writer task
  separada que drena el push channel. Writer compartido bajo
  `Arc<Mutex<WriteHalf<S>>>` para serializar Pong/Error inline con
  los MatchEvents pusheados. Cleanup garantizado en todas las ramas.
  La lógica del post-handshake migra a funciones libres
  (`run_post_handshake`, `handle_inbound_frame`, `cleanup`,
  `broadcast_match_diffs`, `do_handshake`, `register_session`,
  `validate_hello`).
- **Nuevo módulo `brahman-handshake::network`**:
  - `BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"`.
  - `LibP2pHandshakeStream = Compat<libp2p::Stream>` (alias del
    stream una vez convertido al mundo `tokio::io`).
  - `run_libp2p_accept_loop(server, net)`: bucle accept sobre el
    protocolo que delega cada stream entrante a una `Session`
    construida vía `server.session_from_stream(stream.compat())`.
    Sesiones libp2p y Unix conviven en el mismo `Server` —
    comparten broker, push table, last_matches.
  - `connect_libp2p(net, peer, card, wit)`: abre stream libp2p al
    `peer` y arranca handshake.
  - `NetworkError` tipado (`OpenStream`, `Handshake`, `AcceptStream`).

Deps: `brahman-handshake` gana `brahman-net`, `futures`, `tokio-util`.
`brahman-net` re-exporta `Multiaddr`, `PeerId`, `Stream`,
`StreamProtocol`, `Protocol`, `OpenStreamError` para que callers no
necesiten dep directa a libp2p.

Tests:
- 9 tests unit + integration verdes (sin regresión en el path Unix).
- Nuevo `tests/network_libp2p.rs`: test E2E que arma server con
  Unix socket + BrahmanNet, hace listen TCP, monta el accept loop;
  cliente con su propio BrahmanNet dial-ea al peer_id, completa
  handshake remoto, pinguea, farewell. Verifica que la sesión se
  registró durante la conversación y se removió tras farewell.

Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo
flow type, broker query local + remoto).

### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
Primer paso del plan "el encuentro entre Entes no se restringe a local".
El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
a una crate compartida `brahman-net` para que cualquier protocolo de la
familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.

Diseño:
- `BrahmanNet::{new, with_keypair}` arma el swarm con DHT en modo
  Server, Identify auto-poblando el routing table de Kad, y un
  `stream::Control` accesible para que cada protocolo registre su
  `StreamProtocol` y abra/acepte streams sin acoplarse al swarm.
- API de comandos uniforme: `dial`, `listen`, `add_dht_peer`,
  `find_closest_peers`, `start_providing`, `find_providers`.
- Pública: `peer_id` (libp2p) + `control` (stream::Control).
- Re-exporta `Stream` y `StreamProtocol` para que callers no necesiten
  importar libp2p directo.

Migración:
- `minga-p2p::network` reduce de 282 LOC a 22: ahora sólo re-exporta
  `BrahmanNet` bajo el alias histórico `LibP2pNode` (zero churn en
  `MingaPeer`) y declara la const `SYNC_PROTOCOL = "/minga/sync/1.0.0"`
  específica del sub-protocolo Minga.
- Cualquier consumer que necesite armar un nodo P2P puede importar
  `brahman_net::BrahmanNet` directo sin pasar por minga.
- Deps de `minga-p2p` ganan `brahman-net`; el resto del grafo
  (libp2p, libp2p-stream, futures, tokio-util) sigue igual porque
  `MingaPeer` aún los usa para la lógica específica de sync.

Aclaración semántica anclada por el usuario: **Arje** es el init
(PID 1, runtime, ente-zero/kernel/soma); **Brahman** es el encuentro
entre Entes (handshake/broker/card/sidecar/ahora también net). El
nombre de la crate refleja que la malla pertenece al encuentro, no
al runtime — Arje puede usar la malla, Minga usa la malla, cualquier
futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.

Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior
verificado idéntico — sólo se movió código, ningún cambio funcional.
Próximo: Fase 1 (handshake brahman sobre libp2p stream).

### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path
Cierra el único debt estructural detectado en el audit de
independencia: `nouser-explorer` ya no arrastra `nouser-core`
(que aportaba `notify`/`walkdir`/`sled`/`blake3` al grafo de
compilación de una UI que sólo habla JSON contra un socket).

Cambios:
- **Cliente movido**: `engine_socket::client::list_monads` (~60 LOC,
  std + serde_json puros) emigra de `nouser_core::engine_socket` a
  `nouser_card::query::client`. Vive donde viven los wire types,
  consistente con el principio "un consumer importa el contrato,
  no el runtime del productor".
- **Drop dep**: `nouser-explorer` deja de dependener de
  `nouser-core`. Verificado con `cargo tree`: `notify`, `sled`,
  `blake3` desaparecen del grafo del binario. (`walkdir` sigue
  pero llega vía `gpui_util` → `rust-embed`, fuera de nuestro
  control y pre-existente.)
- **Fallback "falla hacia la simplicidad"**: nueva función
  `resolve_socket()` en el explorer intenta primero broker
  discovery; si el broker no responde / no hay init vivo,
  fallback directo a `nouser_card::query::transport::default_socket_path()`.
  El explorer queda funcional contra un daemon "huérfano"
  (corriendo standalone sin init) — completa la cadena
  "consciente cuando hay ecosistema, soberano cuando está solo".
- `socket_source` en el header gana un tercer estado
  `"default-path"` para que el usuario vea por dónde se conectó.

Audit estructural confirmó que el resto del ecosistema ya
respeta el principio: todos los `yahweh-*` viewers, `minga-cli`,
`minga-core`, `nouser-card`, `nouser-nous`, los providers
`nouser-nous-{mock,real}` y `nakui-core` corren standalone con
soft-fail hacia infra brahman cuando está ausente. Brahman es
"pegamento opcional, no chasis obligatorio" — y ahora el grafo
de Cargo lo enforcea, no sólo la convención.

Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes.
El cliente movido se ejercita end-to-end por los 3 tests integración
de `engine_socket` (importa ahora `nouser_card::query::client`).

### feat(card): `Card::new(label)` — alternativa segura a `Default::default()`
Cierra la trap documentada de `Card::default()` que devuelve `id =
Ulid::nil()`. Usar `Card::default()` "viva" colisionaba con cualquier
otra Card default-construida bajo el mismo id `00000000…`. La fix no
es romper `Default` (sigue siendo determinista, requerido por callers
que lo usan como template para deserialización), sino agregar un
constructor explícito:

  let card = Card {
      kind: CardKind::Data,
      payload: Payload::Embedded(json),
      ..Card::new("mi-modulo.algo")
  };

`Card::new(label)` asigna `id = Ulid::new()` (único) + `label`
provisto, dejando el resto en defaults seguros (Virtual / OneShot /
Ente). Pensado para usarse en struct-literals con override parcial,
igual sintaxis que el patrón viejo pero sin la trap.

Refactor de call sites:
- `brahman_sidecar::discovery::build_consumer_card` → `..Card::new(label)`
- `nouser daemon::build_engine_card` → `..Card::new("brahman.nouser_engine")`

`Default` se mantiene tal cual con docstring expandida que advierte
explícitamente sobre el uso "vivo" y apunta a `Card::new`. Tests
existentes y el patrón `nouser_card::MonadManifest::to_brahman_card`
(que asigna el id estable de la Mónada, no uno fresco) NO se
modifican — `Default` sigue siendo correcto cuando el caller
sobreescribe `id` explícitamente.

Tests: 3 unitarios nuevos en brahman-card (`new_assigns_real_ulid_and_label`,
`new_yields_distinct_ids_per_call`, `default_keeps_nil_id_for_struct_update_pattern`).
15 tests verdes (era 12).

### feat(sidecar): API reusable de discovery vía broker
Promueve el patrón ad-hoc `discover_producer_socket` (que vivía
inline en `nouser attract --remote`) a un módulo público
`brahman_sidecar::discovery`. Cualquier consumer puede ahora
preguntar al broker "¿quién provee este TypeRef?" con dos llamadas:

  // Construir un consumer Card mínimo (Ente, Oneshot, Virtual)
  let card = brahman_sidecar::build_consumer_card(
      "mi-cli",
      "embed-result",   // flow.input.name
      "json",            // TypeRef::Primitive { name }
  );

  // Bloqueante (CLIs, std-thread loops):
  let socket: PathBuf = brahman_sidecar::await_provider_blocking(
      card, Duration::from_secs(3),
  )?;
  // O async (módulos con runtime tokio propio):
  let socket = brahman_sidecar::await_provider(card, timeout).await?;

API:
- `build_consumer_card(label, flow_name, type_name) -> Card`
  abstrae la verbosidad del struct-literal repetido en cada caller.
  Genera un `id: Ulid::new()` real (no nil → seguro contra
  colisiones en el broker).
- `await_provider(card, timeout) -> Result<PathBuf, ConsumerError>`
  conecta al init, espera `MatchEvent::Available`, devuelve
  `producer_service_socket`, manda Farewell. Ignora eventos
  `Lost` durante el await (no aplican al arranque).
- `await_provider_blocking(card, timeout)` arma su propio
  runtime `current_thread` para mundos no-async.
- `ConsumerError` con variantes tipadas: `Connect { socket, source }`,
  `NoProvider { flow, type_ref, timeout }`, `Client(ClientError)`,
  `Runtime(String)`. Adiós al `Box<dyn Error>` de antes.

Refactor en `nouser daemon`:
- `discover_producer_socket` (60 LOC inline en `bin/nouser.rs`) → 5
  líneas que delegan en el helper.
- `remote_embed` ya no construye su propio runtime tokio.

Próximo consumer natural: `nouser-explorer`. Hoy renderea
`StatusSnapshot` vía socket admin (introspección pura). El día que
quiera **interactuar** con un Ente — p. ej., disparar un re-embed
desde la UI — usa este helper para resolver el socket del provider
sin hardcodear paths.

Nota sobre identidad: este commit fuerza `Ulid::new()` para los
consumer Cards generados, evitando la trampa documentada del
`Card::default()` que devuelve `Ulid::nil()`. La fijación global de
`Default` queda como cleanup separado (requiere auditar que ningún
caller dependa del determinismo de `nil`).

Tests: 4 unitarios nuevos en `discovery::tests` (id no-nil, id
único por llamada, formateo de TypeRef::Wit, fallback sin input).
Workspace verde.

### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
Para módulos que publican muchas sesiones (nouser daemon con 50+
Mónadas) eso es 50 threads + 50 runtimes. Ahora: **un thread + un
runtime tokio current_thread** que hostea N tasks de sidecar.

API nueva (aditiva, no rompe `spawn`/`spawn_with_handle`):

  let pool = SidecarPool::new()?;
  pool.spawn(card1);
  pool.spawn(card2);
  pool.spawn_conscious(card_wit, wit);
  pool.spawn_with_config(SidecarConfig::new(c).with_wit(w));
  // pool drop = todas las sesiones cierran.

`run_client` se hace pública para que el pool pueda enqueuar tasks
externos al runtime con `handle.spawn(run_client(config))`.

`nouser daemon` migrado al pool. Verificación con `ps -L`:

  $ ps -L -p $(pidof nouser)
    LWP   CMD
    28817 nouser              # main thread
    28819 brahman-sidecar     # pool thread (todas las sesiones)

Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar
cuántas Mónadas se publiquen.

### feat: Crossreferencia — Card.references como grafo del fractal
Las Cards ahora declaran sus relaciones con otras Cards. El Engine
posee Mónadas; las Mónadas declaran que son poseídas por el Engine.
La UI puede cruzar el grafo sin discovery especial.

- `brahman-card`:
  - `RelationshipKind { Owns, OwnedBy, Processes, ProcessedBy, Sibling }`.
  - `CardReference { kind, target_id, target_label }` — `target_label`
    es cache del label en el momento de declarar (la UI puede pintar
    sin resolver).
  - `Card.references: Vec<CardReference>` y espejo en `WireCard`.
    Conversiones `From` propagan.
- `brahman-broker::BrokeredCard` propaga `references`.
- `brahman-status` imprime cada referencia: `ref OwnedBy → label (id)`.
- **nouser daemon**: cada Mónada que publica añade
  `RelationshipKind::OwnedBy` apuntando al engine. La declaración es
  unilateral; el engine no necesita conocer las IDs de antemano.

Validación end-to-end:

  $ ente-zero & nouser daemon crates/core
  $ brahman-status
  Sessions (6):
    [ente]  ... brahman.nouser_engine
    [data]  ... brahman-handshake/src
        ref OwnedBy  →  brahman.nouser_engine  (01K...)
        summary: 6 archivos...
    [data]  ... ente-brain/src
        ref OwnedBy  →  brahman.nouser_engine  (01K...)
    ...

### feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten
Cierra el ciclo del swap automático de Nous (mock↔real):

- **Schema** (`brahman-card`): `Card.service_socket: Option<PathBuf>` y
  espejo en `WireCard`. Conversiones `From` propagan. Es el path del
  **data plane** (distinto del socket del Init); cualquier consumer
  que matchee con esta Card puede conectar directo sin discovery
  adicional.
- **Broker** (`brahman-broker`): `BrokeredCard` propaga
  `service_socket` desde la Card. Sin participación en el matching —
  sólo metadata para los observadores.
- **MatchEvent** (`brahman-handshake`): nuevo campo
  `producer_service_socket: Option<PathBuf>`. Cuando el server emite
  `Available`, busca la `BrokeredCard` del productor en el broker y
  copia su `service_socket`. El consumer recibe la ruta completa para
  conectar.
- **Transport** (`nouser-nous`): `provider_socket_path(provider: &str)`
  devuelve `nouser-nous-{provider}.sock` por default — mock y real
  coexisten en sockets distintos (Phase D-4). `default_socket_path()`
  conserva el comportamiento single-provider.
- **Providers**: mock declara `service_socket =
  /run/user/X/nouser-nous-mock.sock`; real declara
  `nouser-nous-real.sock`. La Card se construye DESPUÉS del bind para
  que el path declarado sea el real.
- **Status**: `brahman-status` imprime `socket:` por sesión cuando
  está presente.

Validación end-to-end:

  $ ente-zero & nouser-nous-mock & nouser-nous-real &
  $ ls /run/user/1001/nouser-nous-*.sock
    nouser-nous-mock.sock
    nouser-nous-real.sock

  $ brahman-status
  Sessions (2):
    [ente]  ... nouser.nous_real
        socket:     /run/user/1001/nouser-nous-real.sock
        in  embed-request: Primitive { name: "json" }
        out embed-result: Primitive { name: "json" }
    [ente]  ... nouser.nous_mock
        socket:     /run/user/1001/nouser-nous-mock.sock
        in  embed-request, out embed-result

Pendientes para futuro (no críticos):
- nouser-core attract --remote todavía usa NOUSER_NOUS_SOCKET hardcoded
  o `default_socket_path()`. El siguiente paso es subscribirse al
  MatchEvent del broker y usar `producer_service_socket` directo —
  con eso `BRAHMAN_BROKER_CONTEXT=test/prod` swapea provider sin
  tocar al consumer.

### feat(broker): priority contexts — biases per-contexto operativo
- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
  declara un override per-contexto.
- `Card.priority_contexts: BTreeMap<String, ContextBias>` y mismo en
  `WireCard` (cruza el wire). Las conversiones `From` lo propagan.
- `BrokerConfig.current_context: Option<String>`. Cuando el broker corre
  bajo un contexto y una Card declara biases para ese nombre, se aplican:
  - Como **consumidor**: `pin_to` sobreescribe el `Flow.pin_to` estático.
  - Como **productor**: `priority_offset` se suma a la priority base
    (clamp en `[Low=0, Critical=3]`) para el ranking.
- `BrokeredCard` propaga `priority_contexts`. `find_producer_for` usa
  `effective_priority(card)` y `effective_pin(card, input)` antes de
  los tiebreaks.
- `brahman-admin::AdminConfig.current_context` + `StatusSnapshot.current_context`
  espejan el contexto activo. `brahman-status` lo imprime como
  `Context: <nombre>` justo debajo de `Init: ...`.
- `ente-zero` lee `BRAHMAN_BROKER_CONTEXT` env var y la propaga al
  broker y al admin. Sin var, biases per-contexto inactivos.
- 4 tests nuevos en brahman-broker:
  `context_priority_offset_lifts_producer_above_alphabetic_winner`,
  `context_pin_to_overrides_static_pin`, `unknown_context_no_op`,
  `priority_offset_clamps_to_critical`.
- Validación end-to-end: `BRAHMAN_BROKER_CONTEXT=test ente-zero` →
  `brahman-status` muestra `Context: test`.

### feat(card): WireCard + extensions — forward-compat sin romper postcard
- `Card.extensions: BTreeMap<String, serde_json::Value>` restaurado con
  `#[serde(flatten, default, skip_serializing_if = is_empty)]`. Los
  campos JSON/TOML desconocidos sobreviven el roundtrip de archivos.
- Nuevo `WireCard`: proyección postcard-friendly (sin `extensions`,
  `genesis: Vec<WireCard>` recursivo). Conversiones `From<Card>` y
  `From<WireCard>` con descarte/recreación de extensions.
- `brahman-handshake::Hello.card` pasa de `Card` a `WireCard`. Client
  hace `card.into()` antes de enviar; Server hace `hello.card.into()`
  para volver a Card antes de validar/registrar.
- 3 tests nuevos en brahman-card:
  `extensions_preserved_in_json_roundtrip`,
  `wire_card_roundtrip_strips_extensions`,
  `wire_card_postcard_friendly` (postcard encode/decode efectivo).
- brahman-card gana `postcard` como dev-dep para el último test.
- Contrato documentado: extensions = anotaciones locales que NO cruzan
  al Init; sólo viven en archivos.

### `9420eae` chore: limpia warnings dead-code en arje (commit del usuario)
- `ente-zero/src/events.rs`: `#![allow(dead_code)]` a nivel módulo —
  es vocabulario de eventos con variantes/campos reservados para flujos
  no cableados aún (CapabilityRequested, ShutdownReason::Signal,
  CapabilityGrant::{Granted, Denied, QuotaExceeded}, ExitStatus
  fields).
- `ente-zero/src/graph/mod.rs`: comentado el re-export ahora innecesario
  de `SHUTDOWN_GRACE`. `DEFAULT_GRANT_TTL` con `#[allow(dead_code)]`
  + nota "reservado para capability granting".
- `ente-zero/src/graph/capabilities.rs`: `renew_grant` con
  `#[allow(dead_code)]` (capability renewal pendiente).
- `ente-kernel/src/surface.rs`: drop de `use anyhow::Context` (no se
  usaba).
- `ente-hostnamed-compat/src/main.rs`: drop de `Connection` (no se
  usaba).
- `ente-polkit-compat/src/main.rs`: `PolicyDecision.source` con
  `#[allow(dead_code)]` (sólo aparece en `Debug` para logging).
- `cargo check --workspace`: 17 warnings → 0.

### feat(sidecar): WIT al sidecar — módulos conscientes vivos
- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
  `PartialEq`, `Eq` para cruzar el wire postcard.
- `brahman-handshake::Hello` lleva `wit: Option<WitInterface>`. Server
  usa `ResolvedCard::from_conscious` cuando viene presente, `from_agnostic`
  cuando no.
- `brahman-handshake::Client::connect` queda como wrapper agnóstico de
  `connect_with(path, card, wit: Option<WitInterface>)`.
- `brahman-broker::Broker::register` ahora toma `Option<WitInterface>`
  como tercer arg. `BrokeredCard` guarda el wit. 25 sitios de tests
  actualizados con `, None`.
- `brahman-sidecar::SidecarConfig` con campo `wit`. Helpers nuevos:
  `SidecarConfig::new(card).with_wit(wit)` y `spawn_conscious(card, wit)`.
  El log `attached` reporta `conscious=true|false`.
- `brahman-status` muestra marker 🧠 + sección `wit:` (package/world,
  imports, exports) por sesión consciente.
- Example nuevo `crates/shared/brahman-sidecar/examples/presence-conscious.rs`:
  toma label + path .wit (default `shared_wit/protocol.wit`), parsea
  con brahman-card-wit, spawna sidecar consciente.
- Validado end-to-end:
  ```
  $ presence-conscious demo.conscious shared_wit/protocol.wit &
  $ brahman-status
  Sessions (1):
    01K... demo.conscious 🧠  lifecycle=Daemon
        wit: brahman:protocol@0.1.0 / module
             imports: types, handshake, lifecycle
             exports: run
  ```

### feat(core): brahman-card-wit — extractor opcional de contratos WIT
- Crate nuevo `crates/core/brahman-card-wit` con `wit-parser = "0.230"`.
- API: `parse_wit(source)` y `parse_wit_file(path)` devuelven
  `Vec<WitInterface>` (uno por `world` declarado).
- Interfaces importadas/exportadas (no sólo funciones) se resuelven
  por nombre via `resolve.interfaces[id].name`.
- Example `crates/core/brahman-card-wit/examples/brahman-wit-info.rs`
  CLI: `brahman-wit-info shared_wit/protocol.wit` → lista paquete,
  worlds, imports y exports.
- 4 tests: inline, archivo real (`shared_wit/protocol.wit`), parse
  error, world vacío.
- Validado contra `protocol.wit`: detecta worlds `module` y
  `admin-host` con sus imports/exports correctos.

### `7b589b8` chore: agrega CHANGELOG.md retroactivo
- `CHANGELOG.md` en la raíz con los 11 commits previos documentados
  acción por acción. A partir de este punto, cada cambio sustantivo
  actualiza también este archivo en el mismo commit.

### `8a83a26` feat(handshake): notificación push de matches
- Frame `MatchEvent { kind: Available | Lost, ... }` añadido al protocolo.
- `Session::run_post_handshake` usa `tokio::select!` para multiplexar
  reads del cliente y un canal `mpsc` push del server.
- Server: `SessionTxTable` (Arc<Mutex<HashMap<SessionId, Sender<Frame>>>>)
  y `LastMatches` para diff por sesión. `broadcast_match_diffs` corre
  tras cada `register` y `unregister`, emite sólo los cambios.
- Capacity del canal push: 32 (ephemeral, `try_send` non-blocking).
- Client: `VecDeque<MatchEvent>` interno, `take_event()` (non-blocking)
  y `await_event(timeout)`. `ping()` ahora drena MatchEvents intermedios
  hasta encontrar el Pong.
- Example `crates/core/brahman-handshake/examples/subscriber.rs`.
- Test `match_event_pushed_on_producer_arrival` (handshake integ 6→7).

### `70a7a0d` feat: segundo módulo (nakui) + admin API + brahman-status
- Crate nuevo `crates/shared/brahman-sidecar` (DRY del thread + tokio +
  ping loop). API: `spawn(card)` / `spawn_with_handle(config)`.
- `nakui` cmd_run llama `brahman_sidecar::spawn` antes de `run_server`.
  Card: lifecycle Daemon, supervision Restart, flow `command` (json) /
  `report` (json).
- Crate nuevo `crates/core/brahman-admin` con `StatusSnapshot` JSON
  line-delim, `AdminServer` y `client::query`.
- ente-zero levanta también el AdminServer en `primordial_loop`.
- Example `crates/shared/brahman-sidecar/examples/presence.rs`
  (módulo dummy long-lived parametrizable por label).
- Example `crates/core/brahman-admin/examples/brahman-status.rs`
  (CLI que pretty-printa el snapshot).
- `brahman-broker`: `BrokeredCard` ahora incluye `lifecycle`. `Endpoint`
  y `Match` derivan `Serialize`/`Deserialize`. Nuevo `Broker::cards()`
  iterador.
- `brahman-card`: `pub use ::ulid` para que módulos no dependan de ulid.
- yahweh-shell migrado al sidecar compartido (96→53 LOC).

### `595f68e` feat(yahweh-shell): primer módulo brahman vivo
- yahweh-shell spawnea sidecar antes de `Application::new()`.
- Card declarada: label `brahman.ui_engine`, lifecycle Widget,
  supervision Delegate, payload Virtual, flow input `render-data`
  (json) / output `user-intent` (json).
- Sidecar en thread aparte con tokio current_thread runtime,
  desacoplado del runtime GPUI.

### `df9d10c` feat(ente-zero): enchufa el handshake server al Init real
- ente-zero levanta `brahman_handshake::server::Server::bind` en
  `primordial_loop` después del ente-bus, con degradación grácil
  si bind falla (mismo patrón que uevents).
- Nuevo módulo `brahman-handshake/src/transport.rs`: helper
  `default_socket_path()` con resolución `BRAHMAN_INIT_SOCKET` →
  `XDG_RUNTIME_DIR` → `TMPDIR`.
- Example `crates/core/brahman-handshake/examples/probe.rs`.
- Validación end-to-end manual: probe contra ente-zero vivo
  imprime `HelloAck: session=... init_attached=true`.

### `07d77a3` feat(handshake): integra el broker con el ciclo de sesiones
- `ServerConfig` acepta `Option<Arc<Mutex<Broker>>>`.
- `register_session` indexa la Card en el broker y la `SessionRegistry`
  antes de emitir HelloAck.
- `Session::handle` refactor a `do_handshake → run_post_handshake →
  cleanup` con cleanup unificado (broker + sessions).
- Tests integ nuevos: `broker_registers_and_unregisters_with_session`
  y `broker_matches_two_live_modules`.
- Fix colateral: `brahman-card::TypeRef` pasa de internally-tagged
  (`#[serde(tag = "kind")]`) a externally-tagged. Postcard no soporta
  internally-tagged en formatos no self-describing. JSON cambia de
  `{"kind":"primitive","name":"x"}` a `{"primitive":{"name":"x"}}`.

### `5091106` feat(core): brahman-broker — matching híbrido
- Crate nuevo `crates/core/brahman-broker`.
- 3 estrategias de matching: `Exact`, `Structural`, `ExactThenStructural`
  (default). Devuelven `Match::via` con la estrategia que ganó.
- Override `pin_to`: el consumer pide un productor por label; si la
  pista no resuelve, cae en type-search.
- Tiebreak por `Card.priority` desc, luego `label` asc (estable y
  determinista).
- API: `register`, `unregister`, `find_producer_for`, `all_matches`,
  `cards`, `sessions`, `len`, `is_empty`.
- 11 tests (matching, pin_to, priority, no-self-loops, all-matches).

### `814390f` feat(core): brahman-handshake — protocolo runtime
- Crate nuevo `crates/core/brahman-handshake` con server y client
  Rust↔Rust sobre Unix socket.
- Frames length-prefixed (4 bytes LE) + cuerpo postcard.
- Mensajes: `Hello`, `HelloAck`, `Ping`, `Pong`, `Farewell`, `Error`.
- `MAX_FRAME_BYTES = 4 MiB` para evitar reservas absurdas.
- Tradeoff: drop `extensions`/`extra` de Card por incompat
  postcard ↔ `serde_json::Value`. Forward-compat queda en
  `schema_version` + `protocol_version` negotiation.
- 4 tests integ + 1 unit en codec.

### `ed0e973` refactor(arje): migra ente-card a re-export de brahman-card
- `ente-card/src/lib.rs` reescrito como crate-shim de re-export
  (327 LOC → 25 LOC).
- `EntityCard` ≡ `brahman_card::Card` por type alias.
- `ente-card/Cargo.toml`: deps reducidas a `brahman-card`.
- `Card` impl `Default` (Ulid::nil(), label vacío) para que
  `..Default::default()` funcione en struct-literals.
- 4 sitios en `ente-zero/src/seed.rs` actualizados con
  `..Default::default()` para los campos aditivos.
- Los 21 consumidores arje compilan sin tocar fuente.

### `0feba74` feat(core): brahman-card — Tarjeta canónica híbrida
- Crate nuevo `crates/core/brahman-card`.
- Hereda de arje: `id: Ulid`, `lineage`, `Capability` tipado,
  `Payload::{Wasm, Native, Virtual, Legacy}`, `SomaSpec`
  (namespaces, cgroups, rlimits, cpu_affinity), `Supervision`
  (Restart con backoff, OneShot, Delegate), `genesis` recursivo.
- Aditivo brahman: `Permissions` enumerados (`NetworkingPolicy`,
  `FsPolicy`, `IpcPolicy`), `Lifecycle` ortogonal a Supervision,
  `Priority` de scheduling, `Flows` con `TypeRef` discriminado
  (Primitive | Wit), `pin_to` opcional.
- `TrustLevel` derivado de `Permissions` (no declarado).
- `ResolvedCard { card, wit: Option<WitInterface>, trust }`.
- Soporta JSON (canónico) + TOML (auto-detectado por extensión).
- 8 tests incluido `arje_seed_format_compatible` que valida que
  el JSON de arje sigue parseando con defaults para los aditivos.

### `4d50bfc` chore: absorbe nakui (ERP matemático) en modules/nakui
- `~/nakui` → `crates/modules/nakui/{core,modules}`.
- `core/`: el crate `nakui-core` con 4 bins (nakui, demo,
  inventory_demo, sales_demo) y tests.
- `modules/{inventory,sales,treasury}/`: data declarativa
  (`nsmc.json`, `schema.k`, `morphisms/`) que el crate consume.
  No son crates Cargo.
- Deps directas (no `workspace = true`): thiserror v1, surrealdb,
  rhai, petgraph. No conflicto con el resto del workspace.

### `53dbdf0` chore: monorepo inicial con arje + minga + yahweh absorbidos
- 45 crates absorbidos en 4 ejes:
  - `crates/core/`: 24 crates de arje (Init systemd-compatible:
    `ente-card`, `ente-zero`, `ente-kernel`, `ente-bus`, `ente-cas`,
    `ente-soma`, `ente-wasm`, `ente-snapshot`, `ente-brain`,
    `ente-echo`, `ente-policy-provider`, + 12 `*-compat`).
  - `crates/modules/semantic_dht/`: 5 crates de minga (`minga-core`
    con AST/CAS/MST, `minga-p2p` con libp2p Kad, `minga-store`,
    `minga-vfs`, `minga-cli`).
  - `crates/modules/ui_engine/`: 11 crates de yahweh (libs/{core,
    theme, bus, providers}, widgets/{tree, splitter, tabs, tiled,
    container_core, text_input}).
  - `crates/apps/`: 5 crates de yahweh (file_explorer,
    database_explorer, text_viewer, image_viewer, yahweh-shell).
- `shared_wit/protocol.wit` con handshake/lifecycle inicial.
- `Cargo.toml` unificado: thiserror bumped a 2 (transparente para
  arje), tokio "full", paths intra-workspace de yahweh redirigidos.
- `cargo check --workspace`: 0 errores (sólo dead-code warnings
  preexistentes en ente-zero).

