Files
sergio bb21c28eb1 feat(mirada): mirada-greeter — greeter de login del escritorio carmen
App GPUI con app_id carmen.greeter: formulario usuario+contraseña que
autentica con brahman-auth en un hilo de fondo y, en éxito, emite un
SessionTicket por stdout para que el compositor haga el traspaso a modo
sesión. Backend mock (MIRADA_GREETER_MOCK) o PAM.

Incluye brahman-auth::SessionTicket (contrato de tiquet greeter→compositor,
serializado a una línea con prefijo versionado) y el modo enmascarado de
nahual-widget-text-input (TextInput::with_mask para contraseñas).

18 tests nuevos; greeter verificado por compilación + clippy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 17:59:12 +00:00

1613 lines
77 KiB
Markdown

# 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).