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

77 KiB

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 MatchEvents.

Variables de entorno respetadas en los 3: BRAHMAN_INIT_SOCKET, BRAHMAN_BROKER_CONTEXT (sólo broker), BRAHMAN_DEMO_LABEL/FLOW/TYPE, RUST_LOG.

Script nuevo scripts/bootstrap-demo.sh:

  • Modos: all (default — broker + producer + consumer + 4 explorers), broker (sin GUIs, sólo backend), only (sólo broker, sin producer/consumer ni GUIs).
  • Cleanup-safe: trap EXIT INT TERM mata todos los procesos spawneados (con SIGTERM grace + SIGKILL fallback) y borra el socket.
  • Espera activa hasta 5s a que el socket aparezca antes de spawnear los siguientes (evita ENOENT en el handshake).
  • Logs separados por proceso bajo $BRAHMAN_DEMO_LOG_DIR (default /tmp/brahman-demo). Re-invocaciones limpian los logs viejos.
  • Re-build automático opcional (comentado por default — asume cargo build --workspace ya hecho).

Smoke verificado end-to-end (sin DISPLAY, sólo backend):

  • Broker arranca, bind del socket OK.
  • Consumer conecta, asigna session.
  • Producer conecta, asigna session.
  • Consumer recibe MatchEvent { Available, demo-feed ← demo-stream, via: Exact, pinned: false } automáticamente — el broker computó el match y lo pusheó por el push channel.

Stack tests: brahman-demo (0 unit), workspace verde.

feat(brahman-handshake): ListMatches endpoint + timeline en broker-explorer

Iter 21. Cierra el loop de observabilidad iniciado en iter 20: ahora se ven no sólo las sesiones registradas sino también qué matches consumer↔producer está computando el broker en cada momento, y la historia de cómo cambian.

brahman-handshake/messages.rs:

  • Frame::ListMatches(ListMatches{session}): pedido (mismo patrón de validación session-id).
  • Frame::MatchList(MatchList{matches: Vec<brahman_broker::Match>}): respuesta. Cada Match ya es serializable y lleva consumer, consumer_label, producer, producer_label, ty, via, pinned.

brahman-handshake/server.rs:

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

brahman-handshake/client.rs: Client::list_matches() análogo a list_sessions(), drena MatchEvents intermedios al buffer.

brahman-sidecar/discovery.rs: list_matches y list_matches_blocking con la misma forma de Card observer minimalista.

brahman-broker-explorer:

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

Tests broker-explorer: 5 totales.

  • diff_matches_first_snapshot_marks_everything_available
  • diff_matches_emits_lost_when_match_disappears
  • diff_matches_no_change_emits_nothing
  • pending_is_default_state_at_boot (existente)
  • poll_and_probe_constants_are_sane (existente)

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

feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer

Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier módulo conectado puede preguntar al broker la lista de sesiones activas y mostrar labels + flows in/out por cada una.

brahman-handshake/messages.rs:

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

brahman-handshake/server.rs:

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

brahman-handshake/client.rs:

  • Client::list_sessions() async: envía el request, drena MatchEvents intermedios al pending_events buffer (mismo patrón que ping), retorna el SessionList.

brahman-sidecar/discovery.rs:

  • pub async fn list_sessions(observer_label) y pub fn list_sessions_blocking(observer_label): arman una Card observer mínima (sin flow.input/output), conectan, piden la lista, Farewell. Para CLIs y módulos std-thread.

brahman-broker-explorer:

  • Cada poll-tick (cuando el broker está UP*) ahora también pide list_sessions_blocking y guarda el snapshot en Explorer.sessions.
  • Render extiende el body con un stat_card "Sesiones activas" que muestra el count + lista ordenada por session (Ulid temporal), cada item: label · in:[flows] out:[flows] (wit?).

Tests:

  • list_sessions_returns_currently_registered: levanta server con broker, conecta 3 clientes (alpha, beta, observer), observer pide list_sessions, verifica los 3 labels presentes y que la entry del observer reporte conscious=false y el schema_version esperado.
  • Stack: handshake suite (24 tests), sidecar (3+8 unit + integ), broker-explorer (4 tests). Todo verde.

feat(brahman-broker-explorer): nueva app probe del broker brahman

Iter 14. Cierra otro frente: visibilidad del broker brahman (el broker handshake que matchea Cards consumer/producer). Hasta ahora no había forma de "ver" si el broker estaba up sin invocar otro binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3 estados claros.

Crate nuevo crates/apps/brahman-broker-explorer/:

  • Deps: brahman-handshake, brahman-sidecar + el stack yahweh themed (theme + 3 widgets). Consume el mismo await_provider_blocking que usa nouser-explorer.
  • ProbeState enum con 4 variants:
    • Pending (estado inicial al boot, antes del primer probe).
    • Down { reason } — connect failed, broker no escucha.
    • UpNoProvider { flow } — broker reachable + sin productor para el flow probado dentro del timeout.
    • UpWithProvider { flow, producer_socket } — broker reachable
      • matcheó algo, devuelve el socket del provider.
  • Polling loop en cx.spawn cada 5s; el probe (que es bloqueante porque internamente usa tokio runtime) se ejecuta en cx.background_executor().spawn(...) para no congelar el main thread del UI.
  • Configuración via env:
    • BRAHMAN_INIT_SOCKET — path del broker (default resuelto por brahman_handshake::transport).
    • BRAHMAN_BROKER_PROBE_FLOW — flow del Card observer (default broker-health).
    • BRAHMAN_BROKER_PROBE_TYPE — type name (default ping).
  • UI: header con probe info + theme switcher; banner permanente (Error/Warning/Success/none según estado) debajo del header; stat card con accent color por estado y descripción.
  • 2 tests sanity (default state es Pending; constants coherentes: PROBE_TIMEOUT < POLL_INTERVAL >= 2s).

Smoke run del binario verificado: bootstrap completo OK, panic esperado en open_window por falta de display.

Beneficio operativo:

  • Si tenés un broker corriendo en ~/.local/share/brahman/init.sock, el explorer lo detecta + reporta estado verde con su socket.
  • Si no hay broker, banner rojo + msg claro indicando el path probado.
  • Si hay broker pero ningún Card produce el flow probado, banner amber — útil para distinguir "broker down" de "broker up, no productor del tipo X".
  • Apuntando el flow/type via env, podés monitor productores específicos: ej. BRAHMAN_BROKER_PROBE_FLOW=monad-list BRAHMAN_BROKER_PROBE_TYPE=json para ver si nouser está sirviendo.

Apps GUI integradas al stack themed: 5 (nakui-ui, nakui-explorer, nouser-explorer, minga-explorer, brahman-broker-explorer).

Limitaciones documentadas:

  • El observer registra una Card temporal en cada probe (cada 5s). Eso ensucia un poco las estadísticas del broker (Cards registradas/desregistradas). No impacta funcionalidad pero inflama el log si el broker tiene observability habilitada.
  • No muestra la lista global de Cards registradas en el broker — el protocolo handshake actual no expone esa API. Para eso habría que agregar un endpoint ListSessions al broker server.
  • No mantiene un buffer de MatchEvents. Cada probe es independiente. Para timeline de matches, hace falta mantener el Client vivo entre probes — scope futuro.

feat(brahman-cards): templates Nickel canónicos para cada body kind

Materializa el patrón "import + override" del brazo: hasta ahora BRAHMAN_CARDS_TEMPLATES_DIR existía como mecanismo pero el repo no shippeaba ningún template. Ahora hay 3 templates basic (uno por body kind del CardBody) bajo crates/core/brahman-cards/templates/:

  • ente_basic.ncl — Card runtime mínima: payload="Virtual", supervision="OneShot", schema_version=1. Override típico: id + label.
  • monad_basic.ncl — agrupación semántica de archivos (Mónada Nouser): metadata vacía, dominant_lens="grid" (lowercase por convención serde rename_all). Override típico: id, label, members, cardinality.
  • ui_module_basic.ncl — descriptor UI con entities=[], menu=[], views={}. Override típico: id, label y los 3 payloads.

Cada field override-able marcada | default (sin eso Nickel rebota merge de strings/numbers no-iguales).

API nueva en lib.rs:

  • pub fn canonical_templates_dir() -> PathBuf: devuelve el directorio de templates del crate (resuelto via CARGO_MANIFEST_DIR). Útil para apuntar el env BRAHMAN_CARDS_TEMPLATES_DIR en runtime/tests sin hardcoding del path.
  • Doc explica que para distribución del binary standalone (cuando emerja), incluir templates como recursos via include_dir! o instalar el directorio junto al ejecutable.

5 tests E2E (tests/templates.rs) que cubren:

  • ente_basic import + override id+label → Card body Ente con payload=Virtual (default preserved).
  • monad_basic import + override id+label+cardinality → Card body Monad con members=[] y summary="" (defaults).
  • ui_module_basic import + override de id+label+menu+views → Card body UiModule con entities=[] (default).
  • Sanity: import sin override → defaults "TEMPLATE_ID" / "TEMPLATE_LABEL" pasan al wrapper sin error.
  • Sanity: el path de canonical_templates_dir() apunta a un directorio existente con los 3 archivos esperados.

Helper de test with_canonical_templates(F) setea/restaura el env localmente; cada test single-thread-safe.

Tests suite brahman-cards: 26 → 31 verdes (+5). El resto del workspace intacto.

Beneficio operativo:

  • Un usuario que quiera declarar un Card nuevo puede empezar con un override de 2 líneas (id + label) en lugar de copiar el shape full desde cero.
  • Templates auto-documentan la convención | default para que copiar uno y agregar fields propios "just works" en merge.
  • El brazo sigue siendo agnostic — los templates son sólo archivos .ncl resueltos via el import resolver Nickel; nada hardcoded en código Rust.

Limitaciones:

  • No hay templates "ricos" tipo crud_basic.ncl que parametricen por entity name. Nickel no expone funciones-templates de la forma típica de templating engines; lo más cercano sería un template con un field entity_name | String y references internas via me.entity_name. Cuando aparezca el caso de uso real (e.g., un módulo donde el patrón list+form es repetitivo), se diseña el template paramétrico.
  • canonical_templates_dir() resuelve via CARGO_MANIFEST_DIR — funciona en cargo (test/run/build) pero no para un binary instalado fuera del workspace. Para release distribution la API necesitará un fallback (resources embedded o convención de install path).

feat(brahman-cards): Nickel reader + templates con merge nativo (V2)

Sigue al V1 (readers JSON). Ahora el brazo acepta inputs .ncl: los evalúa via nickel-lang 2.0, exporta a JSON, y dispatcha por los mismos readers JSON estándar. Un .ncl puede producir cualquier CardBody siempre que su shape sea reconocida. Los templates funcionan con los import + & merge nativos de Nickel — el brazo no inventa una mecánica paralela.

Cambios:

  • Dep nickel-lang = "2.0.0" (interfaz estable, no nickel-lang-core que es internal/inestable). Compila clean pero suma ~1 min al build cold del crate.
  • Nuevo módulo nickel_eval.rs con eval_nickel_file(path) -> Result<Value, NickelEvalError>. Errores tipados: Io, Eval, Export, JsonReparse — el mensaje de Nickel se formatea como texto plano (sin ANSI) para que sea legible en logs y toasts.
  • load_card_with añade "ncl": lee archivo → eval Nickel → exporta a JSON → parsea de vuelta a Value → dispatch a los readers JSON. Pipeline simétrico a "json".
  • CardLoadError::Nickel(NickelEvalError): el error de Nickel se propaga limpio al error público del brazo.
  • Resolución de imports:
    • El parent dir del input se agrega como import path → import "./template.ncl" resuelve sin config.
    • El env BRAHMAN_CARDS_TEMPLATES_DIR (constante exportada BRAHMAN_CARDS_TEMPLATES_ENV) agrega un registry global → import "ui_module_minimal.ncl" desde cualquier ubicación.
    • No hay magic resolución por kind. El autor del Card decide qué template importa.

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

# template ui_module_basic.ncl
{
  id | String | default = "TEMPLATE_ID",
  label | String | default = "TEMPLATE_LABEL",
  ...
}

# uso concreto
let base = import "ui_module_basic.ncl" in
base & { id = "my_id", label = "Mi Label" }

9 tests nuevos en tests/nickel.rs:

  • eval_nickel_file_returns_value_for_valid_input — happy path.
  • eval_nickel_file_surfaces_evaluation_error — variant Eval con path + message.
  • load_card_dispatches_ncl_to_ui_module_variant — pipeline e2e a UiModule.
  • load_card_dispatches_ncl_to_ente_variant — pipeline e2e a Ente.
  • template_merge_overrides_id_and_label_only — el caso del user: template + override de id+label, resto del template intacto.
  • template_resolves_via_env_registry — uso del env como registry global.
  • load_card_wraps_nickel_error_in_card_load_error — wrap limpio del error.
  • nickel_contract_violation_caught_at_eval_time — value-add concreto: id | String = 42 falla en eval, no en deserialize ni aguas abajo.
  • ncl_evaluating_to_unknown_shape_returns_no_matching_reader — sanity de coherencia con dispatcher JSON.

22 tests en total en brahman-cards (13 JSON V1 + 9 Nickel V2). Workspace build verde tras la dep nueva.

Lo que NO hace V2 (sigue pendiente):

  • No migra consumers — nakui-ui sigue cargando con nakui_ui_schema::load_modules_from_dir. La migración a brahman_cards::load_card queda para después.
  • No define un set canonical de templates en el repo (algo como templates/ente_basic.ncl, templates/ui_module_minimal.ncl). Eso emerge cuando aparezcan los primeros casos de uso reales donde dos cards comparten estructura.
  • No hace cross-validation entre template + override (ej: detectar que un override saca un campo required del template). Nickel ya lo hace via contracts si el template tiene un schema.
  • No expone una API streaming (load N cards en paralelo). El use case actual es one-shot al boot.

Pendientes para próximos commits (orden):

  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) -> DecisionAdmit | 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 ∈ denylistDeniedByDenylist.
  2. Si hay allowlist y peer ∉ allowlistNotInAllowlist.
  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.allowlistServerConfig.policy: Option<PeerPolicy> (breaking rename, scope local al monorepo). Gate en do_handshake llama policy.evaluate(&peer) y usa decision.reason() para el mensaje de error tipado.

Wire en Arje (ente-zero):

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

Activación end-to-end con todas las capas activas:

BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \
BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \
ente-zero
# El operador puede editar deny.txt en caliente y la nueva regla
# entra en efecto en ~250ms sin restart del Init.

Tests: 10 unit en peer_policy::tests:

  • open_admits_anyone, allow_only_admits_listed, deny_overrides_open, deny_overrides_allow (deny-first semantics).
  • from_files_with_both_lists, from_files_only_deny, invalid_file_rejected_at_load.
  • reload_picks_up_changes — manualmente recarga y verifica.
  • reload_failure_preserves_previous_state — invariante de seguridad: archivo roto NO baja la política activa.
  • watcher_reloads_on_file_change — E2E del watcher con notify real: muta archivo, espera < debounce + margen, verifica que la política refleja el cambio sin haber llamado reload manualmente.

Plus 1 E2E nuevo en network_libp2p.rs: libp2p_handshake_denylist_blocks_listed_peer — A configura policy = PeerPolicy::from_sets(None, [banned_peer]). Cliente con keypair baneada es rechazado; cliente con keypair distinta pasa el handshake.

30 tests verdes en brahman-handshake (16 unit + 7 handshake + 3 discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin regresión en ente-zero.

Lo que cierra esta entrega:

  • Política completa de admisión: open / allow-only / deny-only / allow+deny.
  • Hot reload sin restart del Init — el operador puede banear/admitir peers en caliente editando archivos.
  • Atomicidad: la recarga es del paquete (allow, deny) completo, no de cada lista por separado. No hay momento donde una lista esté vieja y la otra nueva.
  • Resiliencia: errores de parseo NO bajan la política activa.

Pendientes futuros del changelog:

  • Aplicar la política a nivel de swarm vía libp2p_allow_block_list:: Behaviour (rechazar ANTES del Noise handshake, ahorrar el round-trip TCP+Noise por intento denegado).
  • Rotación de keypair sin perder peer_id (multi-key identity).

feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p

Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora cualquier peer con keypair Ed25519 válida pasaba el handshake remoto; con allowlist activa, sólo los peers explícitamente listados. Aplica únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED del kernel, que es autenticación de proceso local, no de red.

API nueva en brahman_handshake::peer_allowlist:

  • PeerAllowlist::from_iter([peer_id, ...]) para tests/inline.
  • PeerAllowlist::from_file(path) parsea texto plano: un PeerId base58 por línea, # para comentarios (línea entera o inline), líneas vacías ignoradas. Errores de parseo incluyen número de línea para debug rápido.
  • is_allowed(peer), len(), is_empty(), iter().
  • AllowlistError { Io, InvalidPeerId }.

Wire en el server:

  • ServerConfig.allowlist: Option<PeerAllowlist>. None = modo abierto (compat con todo lo anterior). Some = sólo los listados.
  • Gate en do_handshake ANTES de la verificación de firma — la comparación O(log n) en BTreeSet es más barata que crypto, así que rechazamos peers inválidos antes de gastar ciclos. Se devuelve HandshakeError::Unauthorized("peer X no está en la allowlist").

Wire en Arje (ente-zero):

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

Ejemplo de archivo de allowlist:

# Peers permitidos en la malla brahman de prod-eu-1
# Generados con: ente-zero (peer_id loggeado al arrancar)
12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz
12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux  # operador 2

Activación end-to-end:

BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\
ente-zero

Tests:

  • 6 unit en peer_allowlist::tests: from_iter, parse limpio, parse con comentarios inline, parse rechaza PeerId inválido (y reporta número de línea), I/O error en archivo faltante, empty list rechaza todo.
  • 1 E2E en network_libp2p.rs: libp2p_handshake_allowlist_admits_listed_rejects_others. A configura allowlist = [allowed_peer]. Cliente con keypair permitida pasa el handshake (sesión registrada, farewell limpio). Segundo cliente con keypair distinta es rechazado con error ANTES de que se le verifique la firma. Sanidad: el conteo de sesiones del server queda en 0 tras el rechazo.

25 tests verdes en brahman-handshake (12 unit + 7 handshake legacy

  • 3 discovery + 3 libp2p). Ningún regreso en ente-zero (4/4 keypair_store).

Pendiente futuro:

  • Denylist explícita (negada — banear peers específicos sin tener que listar a todos los demás).
  • Hot reload de la allowlist sin restart del Init (signal SIGHUP o watch del archivo).
  • Aplicar la política a nivel de swarm vía libp2p_allow_block_list::Behaviour para rechazar conexiones ANTES del Noise handshake (hoy se rechaza después, gastando un round-trip TCP+Noise por cada intento denegado).

feat(brahman-net+handshake): stop_providing automático en cleanup de sesión

Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión con outputs cerraba (Farewell, EOF, error), el record que la anunciaba en el DHT seguía vivo hasta su TTL natural (~24h en kad default). Consumers remotos podían descubrir un peer "vivo" que ya no servía nada.

Cambios:

  • BrahmanNet::stop_providing(key) (nuevo): contraparte simétrica de start_providing. Manda Command::StopProviding al swarm que llama kad.stop_providing(&key). Borra el record del provider store local al instante; replicas en peers remotos siguen expirando por TTL (kad no expone retracción cross-peer, simétrico al hecho de que start_providing también propaga eventualmente).
  • brahman_handshake::network::withdraw_outputs(net, card) (nuevo): contraparte de announce_outputs. Itera card.flow.output y llama net.stop_providing(flow_dht_key(...)) por cada uno.
  • server::cleanup: extrae la ResolvedCard removida del registro de sesiones (en lugar de descartarla con remove) y, si config.net está set, llama withdraw_outputs(net, &card) antes de broadcast_match_diffs.

Tests: nuevo E2E dht_discovery_withdraws_on_session_cleanup:

  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 PeerIds que sirven el flow.

API nueva en brahman_handshake::network:

  • flow_dht_key(flow_name, type_ref) -> [u8; 32]: blake3 hash de "brahman-flow|v1|{flow}|{type_canon}". Determinístico cross-host. Cambiar la canonicalización rompe compatibilidad — el prefijo v1 documenta la versión del esquema y obliga a bump al modificar.
  • announce_outputs(net, card): llama start_providing en el DHT por cada Flow en card.flow.output. Idempotente, fire-and-forget.
  • find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>: query DHT por la key derivada. Lista vacía si nadie anuncia o si la query no resuelve dentro del timeout interno de Kad.

Wire en el server:

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

Canonicalización del TypeRef:

  • Primitive { name }prim:{name}
  • Wit { package, interface, name }wit:{package}#{interface_or_empty}#{name}

Tests: 2 nuevos en tests/network_discovery.rs:

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

Lo que esto desbloquea:

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

Lo que queda para Fase 3 (trust):

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

feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p

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

Cambios:

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

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

Tests:

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

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

feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)

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

Diseño:

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

Migración:

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

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

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

refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path

Cierra el único debt estructural detectado en el audit de independencia: nouser-explorer ya no arrastra nouser-core (que aportaba notify/walkdir/sled/blake3 al grafo de compilación de una UI que sólo habla JSON contra un socket).

Cambios:

  • Cliente movido: engine_socket::client::list_monads (~60 LOC, std + serde_json puros) emigra de nouser_core::engine_socket a nouser_card::query::client. Vive donde viven los wire types, consistente con el principio "un consumer importa el contrato, no el runtime del productor".
  • Drop dep: nouser-explorer deja de dependener de nouser-core. Verificado con cargo tree: notify, sled, blake3 desaparecen del grafo del binario. (walkdir sigue pero llega vía gpui_utilrust-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-zerobrahman-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>>>) 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_SOCKETXDG_RUNTIME_DIRTMPDIR.
  • 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).
  • EntityCardbrahman_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

  • ~/nakuicrates/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).