Commit Graph

15 Commits

Author SHA1 Message Date
Sergio f9a3c33586 feat(brahman-handshake): multi-key identity — rotacion de session sin perder peer_id logico
Cierra el ultimo 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.

Solucion: separar identity master (Ed25519 persistente forever,
identifica al nodo como entidad logica) de session libp2p (Ed25519
efimera, rotable). El master firma certs de session con expiracion.
La politica de admision se evalua contra el master_peer_id del cert
— el session peer_id puede cambiar libremente sin tocar allowlists.

API nueva en brahman_handshake::identity:
- Identity::from_keypair / master_peer_id / issue_session_cert.
- SessionCert::verify 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).
- CertError tipado: UnknownVersion, DecodeMaster, DecodeSession,
  InvalidSignature, Expired, SessionMismatch, Sign.
- DEFAULT_SESSION_TTL = 24h. SESSION_CERT_VERSION = 1 documenta
  esquema; bump al cambiar canonicalizacion.

Wire:
- Hello.identity_cert: Option<SessionCert> agregado (default None,
  back-compat).
- Client::connect_with_stream_signed_with_cert variante que adjunta
  cert.
- network::connect_libp2p_with_cert paralelo a connect_libp2p.

Server (do_handshake): nuevo paso ANTES del policy gate. Si el
Hello trae cert, verify_against_session(&hello.signature.public_key)
y el logical_peer = master_peer_id derivado. Sin cert (path Fase 3),
logical_peer = expected_peer (compat). Cert invalido -> Unauthorized
antes de evaluar policy.

Migracion gradual: clientes sin cert siguen funcionando contra
servers con policy basada en session peer_ids.

Tests: 8 unit en identity::tests (issue+verify, mismatch, expired,
tampered sig/expires_at, unknown version, rotated_session_with_same_
master_yields_same_master_peer_id — la propiedad fundamental).

E2E definitivo identity_cert_allows_session_rotation_without_policy_
change: A allowlist[master_peer]; B conecta con session1+cert ->
admitido; B rota a session2!=session1 con cert nuevo del MISMO
master -> admitido SIN tocar la allowlist; sanity: session sin cert
es rechazada.

40 tests verdes en brahman-handshake + brahman-net.

Wire en Arje queda como follow-up: ente-zero es server-only y no
necesita identity (su keypair libp2p ya es estable). La API esta
lista para cuando algun modulo haga conexiones salientes con cert.
2026-05-09 15:55:36 +00:00
Sergio 7a0481962e feat(brahman-net+handshake): swarm-level deny via libp2p block_list
Optimizacion 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.

brahman-net:
- Nuevo behaviour block_list: allow_block_list::Behaviour<BlockedPeers>
  añadido al BrahmanBehaviour derivado. Default vacio.
- Nuevos comandos BlockPeer / UnblockPeer en el enum interno.
- API publica: BrahmanNet::block_peer / unblock_peer. Idempotentes.
- Dep nueva: libp2p-allow-block-list 0.6 (sub-crate, no es feature
  de libp2p en 0.56).

brahman_handshake::peer_policy:
- PeerPolicy gana net: Arc<RwLock<Option<Arc<BrahmanNet>>>>. Default
  None preserva callers existentes.
- Nuevo attach_to_net(net): sync inicial (block_peer por cada en
  deny) + guarda net para diff-sync en cada reload.
- reload extendido: snapshot prev_deny ANTES de mutar inner. Tras
  mutar, sync_deny_to_swarm aplica block/unblock por cada
  added/removed.
- Atomicidad preservada: si parse falla, sync no ocurre y la
  version anterior persiste tanto en policy como en block_list.

ente-zero: tras setup_brahman_net + setup_brahman_policy, si AMBOS
estan presentes -> policy.attach_to_net(net.clone()) con log
informativo.

Tests: 1 nuevo E2E swarm_level_deny_blocks_before_noise. A configura
policy con deny + attach_to_net. Cliente baneado intenta connect_libp2p;
en lugar del Unauthorized del handshake, ahora falla con error de
transporte/stream o timeout — el dial nunca completa porque el swarm
rechaza la conexion.

5 tests verdes en network_libp2p.rs. 31 tests totales en brahman-
handshake + brahman-net.

Trade-offs documentados:
- Mas eficiente contra DoS (no consume CPU del Noise por peer baneado).
- Misma fuente de verdad: PeerPolicy. Swarm es cache derivado, sync
  via diff en cada reload, sin drift posible.
- El handshake-level gate sigue activo como segunda linea (defensa
  en profundidad si por bug/race un peer baneado pasa el block_list).
2026-05-09 15:44:03 +00:00
Sergio d98a2b6b7c feat(brahman-handshake+ente-zero): denylist + hot reload de policy de peers
Consolida PeerAllowlist + nueva denylist en un unico PeerPolicy con
allow + deny + hot reload via notify. Cubre los dos pendientes
documentados en el commit anterior y simplifica la API hacia un solo
punto de entrada.

API consolidada en brahman_handshake::peer_policy:
- PeerPolicy::open() — todo permitido (default).
- PeerPolicy::from_sets(allow, deny) — politica inline para tests.
- PeerPolicy::from_files(allow_path?, deny_path?) — carga ambos
  archivos opcionales.
- PeerPolicy::evaluate(peer) -> Decision { Admit | DeniedByDenylist
  | NotInAllowlist }. Decision lleva reason() para logging.
- PeerPolicy::reload() — recarga atomica desde paths asociados.
  Si un archivo falla, conserva la version anterior (un typo no
  baja la politica activa).
- PeerPolicy::spawn_watcher() -> JoinHandle — vigila los archivos
  via notify, debounce 250ms (coalesce de eventos por save), recarga
  atomica al detectar cambio.

Orden de evaluacion: deny-first.
1. peer in denylist -> DeniedByDenylist.
2. allowlist set y peer no in allowlist -> NotInAllowlist.
3. resto -> Admit.

Deny gana sobre allow (un peer en ambas es rechazado): la denylist
es la primitiva de "kill switch".

Watcher: vigila el directorio padre del archivo, no el archivo
mismo. Razon: editores tipicos hacen rename-and-replace que rompe
el watch del archivo pero no del dir. Filtra eventos por path al
procesar.

Wire en server: ServerConfig.allowlist -> ServerConfig.policy:
Option<PeerPolicy> (rename, scope local).

Wire en Arje (ente-zero): nueva env BRAHMAN_PEER_DENYLIST complementa
BRAHMAN_PEER_ALLOWLIST. setup_brahman_policy carga + spawn watcher
y devuelve (policy, JoinHandle) — el handle se conserva en main
para que el thread no aborte.

Activacion completa con todas las capas:
  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
# Editar deny.txt en caliente entra en efecto en ~250ms sin restart.

Tests: 10 unit en peer_policy (incluido watcher_reloads_on_file_change
con notify real) + 1 E2E nuevo libp2p_handshake_denylist_blocks_
listed_peer. 30 tests verdes en brahman-handshake. Sin regresion en
ente-zero.

Lo que cierra: politica completa (open/allow/deny/both), hot reload
sin restart, atomicidad de la recarga, resiliencia ante typos.

Pendientes futuros: aplicar policy a nivel de swarm via
libp2p_allow_block_list::Behaviour (rechazar antes del Noise
handshake), rotacion de keypair sin perder peer_id.
2026-05-09 15:35:00 +00:00
Sergio 505748dd41 feat(brahman-handshake+ente-zero): allowlist explicita de peers libp2p
Capa de politica sobre el trust criptografico de Fase 3. Hasta ahora
cualquier peer Ed25519-valido pasaba el handshake remoto; con
allowlist activa, solo los peers explicitamente listados. Aplica
unicamente al path libp2p — el path Unix sigue usando SO_PEERCRED
del kernel.

API nueva en brahman_handshake::peer_allowlist:
- PeerAllowlist::from_iter / from_file con AllowlistError tipado.
- Formato del archivo: PeerId base58 por linea, # comentarios (linea
  entera o inline), lineas vacias ignoradas. Errores de parseo
  reportan numero de linea.
- is_allowed, len, is_empty, iter.

Wire en el server:
- ServerConfig.allowlist: Option<PeerAllowlist>. None = modo abierto
  (compat). Some = solo los listados.
- Gate en do_handshake ANTES de la verificacion de firma — la
  comparacion BTreeSet O(log n) es mas barata que crypto, asi que
  rechazamos peers invalidos antes de gastar ciclos.
- HandshakeError::Unauthorized("peer X no esta en la allowlist").

Wire en Arje (ente-zero):
- Env var BRAHMAN_PEER_ALLOWLIST apuntando a un archivo.
- setup_brahman_allowlist carga al startup; degrada a None si el
  archivo falla (doctrina PID 1: no romper por subsistemas
  opcionales).

Activacion 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 + 1 E2E en network_libp2p
(libp2p_handshake_allowlist_admits_listed_rejects_others). 25 tests
verdes en brahman-handshake. Sin regresion en ente-zero.

Pendientes: denylist explicita, hot reload via SIGHUP/watch, aplicar
politica a nivel de swarm via libp2p_allow_block_list::Behaviour
para rechazar ANTES del Noise handshake.
2026-05-09 15:27:15 +00:00
Sergio 2e6afd0973 feat(brahman-net+handshake): stop_providing automatico en cleanup
Cierra el pendiente conocido del DHT: hasta ahora cuando una sesion
con outputs cerraba (Farewell, EOF, error), el record que la
anunciaba en el DHT seguia vivo hasta su TTL natural (~24h en kad
default). Consumers remotos podian descubrir un peer "vivo" que ya
no servia nada.

Cambios:
- BrahmanNet::stop_providing(key) (nuevo): contraparte simetrica de
  start_providing. Manda Command::StopProviding al swarm que llama
  kad.stop_providing(&key). Borra el record local al instante;
  replicas remotas siguen expirando por TTL (kad no expone retraccion
  cross-peer, simetrico al hecho de que start_providing tambien
  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) y, si config.net esta 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 via find_remote_providers (assert before contains
   a_peer).
3. Cliente local de A hace farewell -> cleanup -> withdraw_outputs.
4. Espera a que la sesion salga del registro + 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.
2026-05-09 15:10:30 +00:00
Sergio c164e9f422 feat(brahman-handshake): Fase 3 — trust remoto via firma Ed25519
Cuarto 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
podia registrar cualquier Card con cualquier label/flow. Fase 3
exige que el Hello via libp2p venga firmado con la misma keypair
Ed25519 que produce el peer_id autenticado por Noise.

Modelo de trust:
- Local Unix: SO_PEERCRED del kernel autentica. Firma opcional pero
  verificada si presente (defensa en profundidad).
- Remoto libp2p: firma obligatoria; public key del Hello debe derivar
  al peer_id autenticado por Noise. Si falta o no coincide ->
  HandshakeError::Unauthorized.

Wire:
- Hello.signature: Option<HelloSignature> (default None, backwards
  compat para path Unix).
- HelloSignature { public_key: Vec<u8>, signature: Vec<u8> } —
  public_key en formato canonico libp2p (encode_protobuf), firma
  Ed25519 sobre (SIGNATURE_VERSION, WireCard, Option<WitInterface>)
  serializado postcard.

Nuevo modulo signature:
- sign_hello / verify_hello con SignatureError tipado.
- 4 unit tests: roundtrip, peer mismatch, card tampered, sig flipped.

Server:
- Session<S> gana expected_peer: Option<PeerId>.
- session_from_libp2p_stream(stream, peer) para path remoto;
  session_from_stream sin peer para Unix.
- do_handshake exige firma + verifica peer match si expected_peer.

Client:
- connect_with_stream_signed(stream, card, wit, &Keypair) (nuevo).
- connect_libp2p ahora requiere &Keypair (breaking change).

BrahmanNet:
- Almacena Arc<Keypair> internamente; expose keypair() accessor.
  Truco: ed25519::Keypair SI es Clone, se duplica para que swarm
  (Noise) y caller (signing) compartan identidad sin copiar bytes.
- with_keypair rechaza no-Ed25519.

Tests: 4 unit signature + 1 E2E negativo nuevo
(libp2p_handshake_rejects_mismatched_signing_key) + E2E positivo
ya pasaba con keypair correcta. 90+ tests verdes en
brahman-handshake/brahman-net/brahman-card/minga-p2p.

Lo que cierra: la cadena completa de discovery + connect + trust
funciona cross-machine sin paths hardcodeados ni confianza
implicita. Brahman-net es una malla publicamente dial-able CON
autenticacion criptografica end-to-end.

Pendientes futuros: stop_providing en cleanup, wire de Arje con
ServerConfig.net configurado, allowlist/denylist de peers,
persistencia de la keypair entre reboots.
2026-05-09 14:50:04 +00:00
Sergio 2059af4fb9 feat(brahman-handshake): Fase 2 — discovery remoto via DHT por flow type
Tercer paso del plan "el encuentro entre Entes no se restringe a
local". Cuando un Init local acepta una sesion cuya Card declara
outputs, anuncia al DHT (Kademlia, via brahman-net) que el 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}". Determinista cross-host.
  Cambiar la canonicalizacion rompe compatibilidad — el prefijo v1
  documenta version del esquema y obliga a bump al modificar.
- announce_outputs(net, card): start_providing por cada flow.output.
  Idempotente, fire-and-forget.
- find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>:
  query DHT. Lista vacia si nadie anuncia.

Wire en el server:
- ServerConfig gana pub net: Option<Arc<BrahmanNet>>. Si esta set,
  cada Card registrada con outputs se anuncia automaticamente al DHT
  desde register_session. None = server "ciego al DHT".
- Debug manual de ServerConfig (BrahmanNet no es Debug).

Canonicalizacion 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: 2 nodos, A registra Card con
  flow.output = monad-list:json, B dial-ea a A, B llama
  find_remote_providers y descubre el peer_id de A.
- dht_discovery_negative_unknown_flow: B busca flow inexistente,
  devuelve [] sin colgarse.

Callers actualizados con net: None: tests existentes + ente-zero
(arje aun no expone red; pasar Some(Arc<BrahmanNet>) cuando quiera
publicar al DHT remoto).

Lo que esto desbloquea: un nouser daemon en maquina A puede ser
descubierto por nouser-explorer en maquina B sin conocimiento previo
del peer — solo necesitan compartir DHT (via bootstrap inicial).

Pendiente para Fase 3: trust (firma Ed25519 en Cards remotas) +
stop_providing al cleanup de sesion.
2026-05-09 14:37:15 +00:00
Sergio 73dadbb166 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 tambien
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> genericos: ambos dejan de estar atados a
  UnixStream y pasan a ser genericos sobre S: AsyncRead + AsyncWrite
  + Unpin + Send + 'static. El path Unix queda como
  Client = Client<UnixStream> (default generico). 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 requeria 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 logica del post-handshake migra a funciones libres
  (run_post_handshake, handle_inbound_frame, cleanup,
  broadcast_match_diffs, do_handshake, register_session,
  validate_hello).

- Nuevo modulo brahman-handshake::network:
  - BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"
  - LibP2pHandshakeStream = Compat<libp2p::Stream>
  - run_libp2p_accept_loop(server, net): accept loop que delega cada
    stream entrante a 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.

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 verdes en el path Unix (sin regresion). Nuevo
tests/network_libp2p.rs E2E que arma server con BrahmanNet, hace
listen TCP, monta accept loop; cliente con su propio BrahmanNet
dial-ea al peer_id, completa handshake remoto, ping, farewell.
Verifica que la sesion se registro durante la conversacion y se
removio tras farewell.
2026-05-09 12:51:43 +00:00
Sergio 5edc912ed8 feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten
Cierra el ciclo del swap automático Nous mock↔real:

- brahman-card: Card.service_socket: Option<PathBuf> y espejo en
  WireCard. Path del data plane (distinto al Init). Cualquier
  consumer que matchee con esta Card conecta directo, sin discovery
  extra.
- brahman-broker: BrokeredCard propaga service_socket. Sin
  participación en matching — sólo metadata.
- brahman-handshake::MatchEvent: nuevo campo
  producer_service_socket. Server lo busca en BrokeredCard al emitir
  Available.
- nouser-nous::transport: 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.
- Mock declara nouser-nous-mock.sock; real declara
  nouser-nous-real.sock. La Card se construye DESPUÉS del bind.
- 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
    [ente]  nouser.nous_mock
        socket: /run/user/1001/nouser-nous-mock.sock

Pendiente (no crítico): nouser-core attract --remote usa todavía
NOUSER_NOUS_SOCKET hardcoded. Siguiente paso: subscribirse al
MatchEvent del broker y usar producer_service_socket directo, así
BRAHMAN_BROKER_CONTEXT=test/prod swapea provider sin tocar al
consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:38:23 +00:00
Sergio f19ca723b6 feat(card): WireCard + extensions — forward-compat sin romper postcard
Restaura el campo extensions de Card que había caído al adoptar postcard
(serde_json::Value usa secuencias/maps de longitud dinámica). La
solución es separar dos formas:

- Card (la rica): para JSON/TOML. Tiene extensions: BTreeMap<String,
  serde_json::Value> con #[serde(flatten, skip_serializing_if = is_empty)].
  Los campos desconocidos del archivo sobreviven el roundtrip.
- WireCard (la slim): para postcard. Mismo schema sin extensions y con
  genesis: Vec<WireCard> recursivo. Postcard-friendly por construcción.

Conversiones From<Card> for WireCard (descarta extensions) y
From<WireCard> for Card (extensiones quedan vacías post-wire). El
contrato es explícito: extensions son anotaciones locales que sobreviven
file I/O pero NO cruzan al Init.

brahman-handshake::Hello.card cambia de Card a WireCard. Client hace
card.into() al enviar; Server hace hello.card.into() para volver a
Card antes de validar/registrar.

Tests:
- 3 nuevos en brahman-card: extensions_preserved_in_json_roundtrip,
  wire_card_roundtrip_strips_extensions, wire_card_postcard_friendly
  (verifica que postcard::to_allocvec(&wire) NO falla — caso que
  rompía con Card.extensions populadas).
- 1 ajuste en handshake/tests/handshake.rs (struct-literal de Hello
  ahora con card: sample_card(...).into()).
- brahman-card: postcard como dev-dep.

Tests acumulados: 35 (card 11, broker 11, handshake codec+transport 2 +
integ 7, card-wit 4, admin 0). 0 errores, 0 warnings (vienen del
commit anterior 9420eae).

CHANGELOG.md actualizado con esta entrada y con el commit 9420eae
("probando" del usuario, limpieza de 17 warnings dead-code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:33:15 +00:00
Sergio 354f992c63 feat(sidecar): WIT al sidecar — módulos conscientes vivos
Cierra el ciclo brahman-card-wit ↔ runtime: un módulo que tenga su
.wit lo parsea, lo manda en Hello, y aparece como "consciente" en el
broker y en brahman-status.

Cambios coordinados (un solo commit por la cadena de tipos):

- brahman-card::WitInterface deriva Serialize/Deserialize/Eq.
- brahman-handshake::Hello lleva wit: Option<WitInterface> (#[serde(default)]
  para tolerar Hellos antiguos en formato JSON aunque postcard exige
  presencia explícita).
- Server's register_session enruta a ResolvedCard::from_conscious cuando
  viene wit; from_agnostic cuando no.
- Client::connect queda como wrapper de connect_with(path, card,
  wit: Option<WitInterface>) — backward-compatible.
- Broker::register acepta Option<WitInterface> como tercer arg; BrokeredCard
  guarda el wit. 25 sitios de tests actualizados con `, None` (vía perl).
- brahman-sidecar::SidecarConfig.wit + helpers SidecarConfig::with_wit
  y spawn_conscious(card, wit). Log attached reporta conscious=true|false.
- brahman-status pretty-print con 🧠 + sección wit (package/world +
  imports + exports) por sesión consciente.
- Example nuevo presence-conscious: parsea protocol.wit y se presenta
  consciente.

Validación end-to-end manual:

  $ ente-zero &
  $ 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

Tests: 32/32 (broker 11 + card 8 + handshake codec+transport 2 + integ 7
+ admin 0 + card-wit 4). Workspace: 0 errores.

CHANGELOG.md actualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:22:48 +00:00
Sergio 8a83a26de0 feat(handshake): notificación push de matches del broker al cliente
El servidor empuja MatchEvent (Available | Lost) a los consumers cuando
sus inputs cambian de match — sea porque un productor llegó, porque
otro mejor lo desplazó, o porque desapareció.

Mecánica:

- Frame::MatchEvent con MatchEventKind { Available, Lost } y los datos
  del match (consumer_flow, producer_session/label/flow, ty, via, pinned).
- Server: SessionTxTable (Arc<Mutex<HashMap<SessionId, mpsc::Sender>>>)
  + LastMatches (último match conocido por consumer/input). En cada
  register/unregister, broadcast_match_diffs recomputa con el broker
  y emite SOLO los diffs respecto al estado anterior.
- Session::run_post_handshake usa tokio::select! para multiplexar
  read_frame del cliente y rx.recv() de su tx push.
- Cleanup ahora también limpia push_table y last_matches y dispara un
  broadcast (para notificar a quienes pierden el match).
- Client: VecDeque<MatchEvent> bufferea eventos que llegan mezclados
  con respuestas a Ping. API:
    - take_event() — non-blocking, drena buffer
    - await_event(timeout) — bloquea hasta evento o timeout
- ping() ahora drena MatchEvents intermedios hasta encontrar el Pong.

Capacity del canal push por sesión: 32 frames (try_send no-blocking;
si se llena, los eventos extra se descartan — se documenta como
ephemeral, el cliente puede re-consultar via brahman-status).

Test nuevo en brahman-handshake/tests/handshake.rs:
- match_event_pushed_on_producer_arrival: consumer espera, no recibe
  evento → llega productor → recibe Available → productor se va →
  recibe Lost.

Example nuevo: brahman-handshake/examples/subscriber.rs — cliente que
loguea cada MatchEvent en tiempo real. Útil para ver la dinámica del
broker. Pings cada 25s para keepalive.

Demo end-to-end verificada (4 eventos, 3 ya cubren el ciclo completo):

  T+0.3  alpha llega    → Available ← demo.alpha.out
  T+0.8  beta llega     → (sin evento: alpha gana por orden alfabético)
  T+1.3  alpha killed   → Available ← demo.beta.out (re-evaluación)
  T+1.8  beta killed    → Lost ← <none>

El broker emite diff: ningún evento cuando un nuevo productor llega
sin desplazar al ganador actual.

Tests: 28/28 (handshake integ 6→7). cargo check --workspace: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:43:41 +00:00
Sergio df9d10cc52 feat(ente-zero): enchufa el handshake server al Init real
ente-zero (PID 1 del fractal arje) ahora levanta el server de
brahman-handshake junto al ente-bus existente, escuchando en
\$BRAHMAN_INIT_SOCKET (default \$XDG_RUNTIME_DIR/brahman-init.sock).
Es un canal paralelo dedicado a módulos brahman-conscientes que se
presentan con una Card y declaran flujos tipados.

Cambios:

- crates/core/brahman-handshake/src/transport.rs: helper nuevo con
  resolución XDG_RUNTIME_DIR → TMPDIR, override por var de entorno
  BRAHMAN_INIT_SOCKET. Test unitario para el override.
- crates/core/brahman-handshake/Cargo.toml: example "probe" + dev-dep
  anyhow. Probe sirve como herramienta de diagnóstico para conectar
  contra cualquier server vivo.
- crates/core/brahman-handshake/examples/probe.rs: cliente mínimo que
  hace Hello → Ping → Farewell e imprime el HelloAck recibido.
- crates/core/ente-zero/Cargo.toml: dependencias brahman-handshake
  + brahman-broker.
- crates/core/ente-zero/src/main.rs: en primordial_loop, tras spawn
  del ente-bus, crea Arc<Mutex<Broker>> compartido y llama
  Server::bind. Si el bind falla (FS no escribible, socket en uso),
  loggea y degrada a "modo bus-only" — la doctrina PID 1 no rompe por
  subsistemas opcionales (mismo patrón que uevents).

Validación end-to-end manual:
  $ BRAHMAN_INIT_SOCKET=/tmp/e2e.sock ./target/debug/ente-zero &
  $ BRAHMAN_INIT_SOCKET=/tmp/e2e.sock cargo run --example probe
    HelloAck: session=01KR41Q8... server=0.1.0 protocol=0.1.0 init_attached=true
    Pong: ts=1778252489714ms
    Farewell OK

Tests: 27/27 (broker 11 + card 8 + handshake codec+transport 2 + integ 6).
cargo check --workspace: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:02:29 +00:00
Sergio 07d77a335f feat(handshake): integra el broker con el ciclo de sesiones
ServerConfig acepta un Option<Arc<Mutex<Broker>>> compartido. Cuando está
presente, el servidor lo mantiene en sincronía con las sesiones:

- Tras un Hello aceptado, register_session indexa la Card en el broker
  ANTES de insertar en el SessionRegistry y de emitir HelloAck.
- Al cerrar la sesión (Farewell, EOF, o error en run_post_handshake), un
  cleanup() unificado llama unregister en el broker y remove en el
  SessionRegistry. Garantizado por refactor de Session::handle a
  do_handshake → run_post_handshake → cleanup.

Tests nuevos en handshake.rs:
- broker_registers_and_unregisters_with_session: confirma el ciclo
  register → farewell → unregister.
- broker_matches_two_live_modules: dos clientes (productor + consumidor)
  conectados; el broker resuelve find_producer_for(consumer.session, "in")
  → producer "dht". Tras farewell del productor, el match desaparece.

Fix colateral: brahman-card::TypeRef pasa de internally-tagged
(#[serde(tag = "kind")]) a externally-tagged (default). Postcard no
soporta internally-tagged en formatos no self-describing — sin este
cambio el wire de Hello con Cards que tengan flujos no codificaba.
JSON cambia de {"kind":"primitive","name":"x"} a
{"primitive":{"name":"x"}}. Documentado en el doc-comment de TypeRef.

26/26 tests verdes (broker 11 + card 8 + handshake codec 1 + integ 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:54:45 +00:00
Sergio 814390feec feat(core): brahman-handshake — protocolo runtime Init↔módulo
Crate nuevo en crates/core/brahman-handshake que implementa el handshake
real del shared_wit/protocol.wit como wire format Rust↔Rust sobre Unix
socket con frames length-prefixed + cuerpo postcard.

Componentes:

- src/messages.rs: Hello, HelloAck, Ping, Pong, Farewell, HandshakeError
  y Frame (enum-suma). HandshakeError ya implementa thiserror::Error y
  cruza el wire.
- src/codec.rs: write_frame / read_frame asíncronos con MAX_FRAME_BYTES
  de 4 MiB. Test interno de roundtrip.
- src/server.rs: Server::bind crea el listener en Unix socket; emite
  ResolvedCard tras validar la Card y devuelve ULID como SessionId.
  ServerConfig.init_attached se reporta en HelloAck.
- src/client.rs: Client::connect hace pre-validación local de la Card
  (fail fast), envía Hello, parsea HelloAck. ping() y farewell() expuestos.
- tests/handshake.rs: 4 tests de integración:
   * full_handshake_roundtrip — happy path con 3 pings + farewell
   * rejects_invalid_card_client_side — label vacío rechazado pre-envío
   * server_rejects_protocol_mismatch — protocol_version 999.0.0 → Error
   * ping_before_hello_rejected — Ping sin Hello previo → Rejected

Limitación conocida: postcard no serializa serde_json::Value (variantes
Array/Object con length dinámico). Se removieron por eso los campos
`extensions` (Card) y `extra` (Permissions). Forward-compat queda
cubierta por schema_version + protocol_version negotiation; si más
adelante necesitamos preservar campos JSON desconocidos, irá en un
WireCard separado o un envelope.

13/13 tests verdes (brahman-card 8 + brahman-handshake codec 1 + integ 4).
cargo check --workspace: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:57:49 +00:00