gioser

tawasuyu/chasqui

Sovereign P2P peer discovery + transport (DHT, relay, NAT traversal) in Rust.

feat: chasqui standalone — descubrimiento + transporte P2P soberano (DHT, relay, NAT traversal) (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (8 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

5d5e4a3ae4e92f264765a11cfc6a5c9079b1eb5a · por Sergio · (hace 4 meses) · ver el árbol en este commit

Delta semántico · 592

02_ruway/chasqui/card-admin/examples/brahman-status.rs

02_ruway/chasqui/card-admin/src/client.rs

02_ruway/chasqui/card-admin/src/lib.rs

02_ruway/chasqui/card-admin/src/server.rs

02_ruway/chasqui/card-admin/src/snapshot.rs

02_ruway/chasqui/card-admin/src/transport.rs

02_ruway/chasqui/card-handshake/examples/probe.rs

02_ruway/chasqui/card-handshake/examples/subscriber.rs

02_ruway/chasqui/card-handshake/src/client.rs

02_ruway/chasqui/card-handshake/src/codec.rs

02_ruway/chasqui/card-handshake/src/identity.rs

02_ruway/chasqui/card-handshake/src/lib.rs

02_ruway/chasqui/card-handshake/src/messages.rs

02_ruway/chasqui/card-handshake/src/network.rs

02_ruway/chasqui/card-handshake/src/peer_policy.rs

02_ruway/chasqui/card-handshake/src/server.rs

02_ruway/chasqui/card-handshake/src/signature.rs

02_ruway/chasqui/card-handshake/src/transport.rs

02_ruway/chasqui/card-handshake/tests/network_discovery.rs

02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs

02_ruway/chasqui/card-sidecar/examples/presence.rs

02_ruway/chasqui/card-sidecar/src/discovery.rs

02_ruway/chasqui/card-sidecar/src/lib.rs

02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs

02_ruway/chasqui/chasqui-broker/src/lib.rs

02_ruway/chasqui/chasqui-card/src/lib.rs

02_ruway/chasqui/chasqui-card/src/query.rs

02_ruway/chasqui/chasqui-core/src/bin/nouser.rs

02_ruway/chasqui/chasqui-core/src/db.rs

02_ruway/chasqui/chasqui-core/src/embed.rs

02_ruway/chasqui/chasqui-core/src/engine_socket.rs

02_ruway/chasqui/chasqui-core/src/lib.rs

02_ruway/chasqui/chasqui-core/src/scanner.rs

02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs

02_ruway/chasqui/chasqui-nous-mock/src/main.rs

02_ruway/chasqui/chasqui-nous-real/src/cache.rs

02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs

02_ruway/chasqui/chasqui-nous-real/src/main.rs

02_ruway/chasqui/chasqui-nous-real/src/stub.rs

02_ruway/chasqui/chasqui-nous/src/lib.rs

80 archivos · +22318 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/chasqui/ARQUITECTURA.md b/02_ruway/chasqui/ARQUITECTURA.md
new file mode 100644
index 0000000..bddc6a5
--- /dev/null
+++ b/02_ruway/chasqui/ARQUITECTURA.md
@@ -0,0 +1,202 @@
+# ARQUITECTURA.md — chasqui
+
+> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
+> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs.
+
+```yaml
+DOMINIO: chasqui
+CUADRANTE: 02_ruway (HACER)
+NOMBRE: quechua "mensajero del camino del Inca"
+ADVERTENCIA_SEMÁNTICA:
+  El README/LEEME aspira a "broker de mensajería + bus tipado pub/sub".
+  El CÓDIGO ACTUAL implementa otra cosa: NO hay transporte de mensajes app↔app en tiempo real.
+  La aspiración pub/sub migró al dominio Ayni (chat P2P). Ver project_ayni_chat.md.
+TESIS_REAL: dominio DUAL — (A) matcher determinista de tipos entre módulos + (B) inteligencia de datos semántica
+TAMAÑO: 11 crates, ~8.7 KLoC. Brahman ≈4.3K · Nouser ≈4.4K
+```
+
+## Los dos subsistemas (no confundir)
+
+```
+SUBSISTEMA A — BRAHMAN CARD BROKER  (plano de TIPOS / control)
+  Registry + matcher determinista de FLUJOS tipados entre Cards (módulos con interfaz WIT opcional).
+  Responde "¿quién produce el flujo que este consumer necesita?". NO mueve datos: hace matching.
+  Crates: chasqui-broker · card-handshake · card-sidecar · card-admin · chasqui-broker-explorer-llimphi
+
+SUBSISTEMA B — NOUSER MONADS + EMBEDDINGS  (plano de DATOS / semántica)
+  Escanea directorios → agrupa archivos en MÓNADAS (clusters semánticos) → enriquece con embeddings
+  → permite búsqueda/atracción por centroide. Persiste en sled. UI consulta.
+  Crates: chasqui-core · chasqui-card · chasqui-nous · chasqui-nous-mock · chasqui-nous-real · chasqui-explorer-llimphi
+
+NEXO entre A y B:
+  Los proveedores de embeddings (nous-mock/real) se REGISTRAN como Cards en Brahman.
+  Un consumidor que pide "embed-request:json" es matcheado por el broker al proveedor correcto según CONTEXTO.
+  => B usa A para descubrir QUÉ proveedor de embeddings está vivo y debe ganar.
+```
+
+## SUBSISTEMA A — Brahman Broker
+
+### Tipos núcleo (`chasqui-broker/src/lib.rs`, 995 LOC, lib pura sin IO)
+
+```rust
+enum MatchStrategy { Exact, Structural, ExactThenStructural }
+  // Exact      = TypeRef idéntico (interface+package+name)
+  // Structural = mismo package+name, ignora interface (o mismo Primitive)
+  // ExactThenStructural = prefiere exact, cae a structural
+
+struct BrokeredCard {
+  session: SessionId,        // ULID del handshake
+  label: String, lifecycle: Lifecycle, priority: Priority,
+  inputs: Vec<Flow>, outputs: Vec<Flow>,
+  wit: Option<WitInterface>, // matching fino si el módulo es "consciente"
+  priority_contexts: BTreeMap<String, ContextBias>,  // sesgos por contexto ("test"/"prod")
+  kind: CardKind /*Ente|Data*/, data: Option<DataFacet>, service_socket: Option<PathBuf>,
+}
+struct Match { consumer: Endpoint, producer: Endpoint, ty: TypeRef, via: MatchStrategy, pinned: bool }
+struct ContextBias { pin_to: Option<String>, priority_offset: i16 }
+struct Broker { cards: BTreeMap<SessionId, BrokeredCard>, config: BrokerConfig }
+```
+
+### Algoritmo `find_producer_for(consumer, input_name)`
+
+```
+1. PIN: si el input (o priority_contexts[ctx].pin_to) fija un label → buscar productor con ese label (type-agnóstico).
+2. TYPE-SEARCH: filtrar outputs de otras Cards por compatibilidad según strategy.
+3. RANK: ordenar por effective_priority(producer) DESC, luego label ASC (tie-break determinista).
+4. → Match { ... via, pinned }.
+PROPIEDADES: determinista (mismas Cards + mismo contexto ⇒ mismo resultado); O(n·m); stateless en rutas.
+```
+
+### Handshake e infra (protocolo nativo Rust↔Rust, NO WIT/WASM)
+
+```
+card-handshake (2942 LOC): Hello{card} → HelloAck{session_id: ULID} sobre Unix socket.
+  Frames length-prefixed + postcard. Sesión viva con Ping(~30s)/Farewell. Deriva TrustLevel (agnóstico vs WIT-consciente).
+card-sidecar (565): thread tokio current_thread que mantiene la sesión. API await_provider(card,timeout), list_matches().
+card-admin (225): Unix socket SEPARADO; emite StatusSnapshot JSON (sesiones+matches). Single-shot/conexión. bin `brahman-status`.
+```
+
+### INVARIANTES (A)
+
+```
+A-INV-1  El broker NO rutea datos; sólo computa matches de tipo bajo demanda. El data-plane lo abre cada módulo (service_socket).
+A-INV-2  Matching determinista y reproducible: orden total por (priority efectiva, label).
+A-INV-3  pin_to gana sobre type-search; priority_contexts[ctx] override estático. Contexto activo = BRAHMAN_BROKER_CONTEXT.
+A-INV-4  El broker vive en memoria del Init. NO hay snapshot/recover al reboot (deuda → ver ASPIRA).
+```
+
+## SUBSISTEMA B — Nouser
+
+### Mónada (`chasqui-card/src/lib.rs`, 709 LOC)
+
+```rust
+struct FileEntry { id: FileId/*ULID*/, path, content_hash: Option<[u8;32]>/*blake3*/, size, mtime_ms, extension }
+enum Lens { Grid, Code, Gallery, Database, Markdown, Tree }   // cómo se visualiza el cluster
+struct MonadManifest {
+  schema_version: u16/*=1*/, id: MonadId/*ULID, ordenable por tiempo*/, lineage: Option<MonadId>,
+  label, summary, centroid: Vec<f32>, centroid_model: Option<String>/*"chasqui-pseudo-32d"|"real-fastembed-384d"*/,
+  path_hint: Option<String>/*dir padre canónico = identidad estable*/, keywords: Vec<String>,
+  cardinality: u32, entropy: f32/*[0,1] cohesión de extensiones*/, dominant_lens: Lens,
+  pins: BTreeSet<FileId>/*anclados, no migran*/, members: BTreeSet<FileId>,
+  created_at_ms, updated_at_ms, extensions: BTreeMap<String, Value>/*forward-compat*/,
+}
+```
+
+### Pipeline determinista (`chasqui-core`, 2215 LOC)
+
+```
+scanner.rs   recorre directorios → Vec<FileEntry>
+cluster.rs   by_directory: agrupa por (dir padre + extensión dominante) → MonadManifest
+db.rs        MonadDb: store en memoria + sled opcional
+embed.rs     embed(file) → [f32;32] L2-normalizado, DETERMINISTA sin LLM:
+               dims 0..8 blake3(extension) · 8..16 blake3(parent) · 16..24 blake3(stem) · 24..28 size(log) · 28..32 mtime(cíclico)
+               EMBED_DIM=32, MODEL_ID="chasqui-pseudo-32d"
+CLI:  nouser scan|show|json|daemon|attract
+```
+
+### Contrato Nous (`chasqui-nous`, 196 LOC) — proveedor de embeddings intercambiable
+
+```
+WIRE: JSON line-delimited sobre Unix socket, single-shot/conexión.
+  EmbedRequest{ kind: EmbedFile|EmbedText|Ping, payload: Value }
+  EmbedResponse{ embedding: Vec<f32>, model: String, elapsed_ms: u64 }
+SOCKET: $XDG_RUNTIME_DIR/chasqui-nous-{provider}.sock  (override $NOUSER_NOUS_SOCKET)
+
+mock (chasqui-nous-mock): chasqui_core::embed, 32d determinista; Card priority_contexts["test"]={+1}  → gana en test
+real (chasqui-nous-real): fastembed+ONNX all-MiniLM-L6-v2, 384d; feature `embeddings`; cache sled;
+                          Card priority_contexts["prod"]={+1}  → gana en prod
+=> El broker Brahman elige mock vs real según BRAHMAN_BROKER_CONTEXT. Mismo flow embed-request:json / embed-result:json.
+```
+
+### INVARIANTES (B)
+
+```
+B-INV-1  embed() es puro y determinista (hash+metadata) ⇒ misma carpeta ⇒ mismas Mónadas y centroides en toda máquina.
+B-INV-2  path_hint es la identidad estable de una Mónada (sobrevive a cambios de miembros).
+B-INV-3  El proveedor de embeddings es opaco/intercambiable tras el contrato Nous; el core no sabe si es mock o LLM.
+```
+
+## UIs Llimphi
+
+```
+chasqui-broker-explorer-llimphi (599): probe de salud; poll 5s await_provider_blocking →
+  estado Down | UpNoProvider | UpWithProvider(label,socket) + timeline de MatchEvent (últimos 50).
+chasqui-explorer-llimphi (501): explorador de Mónadas; descubre el daemon nouser vía broker,
+  consulta Mónadas, filtra por centroide (búsqueda semántica), muestra cardinality/entropy/lens.
+```
+
+## Relaciones inter-dominio
+
+```
+agora   : identidad Ed25519 — autores de mensajes en el futuro chat (vía Ayni), no en chasqui hoy.
+minga   : transporte P2P libp2p (BrahmanNet) — chasqui aún NO lo usa; el broker es LOCAL/LAN (Unix socket).
+akasha  : VFS content-addressed en wawa — no usado por chasqui hoy.
+rimay   : chasqui-explorer-llimphi → rimay-localize para búsqueda semántica sobre centroides.
+shuma   : chasqui-core → shuma-discern (clasificación determinista) — integración superficial, pendiente profundizar.
+arje    : chasqui-nous-real podría persistir embeddings en CAS de wawa — experimental.
+ayni    : HEREDA la aspiración pub/sub de chasqui. Ayni-sync usa trait Transporte (EnlaceMinga), NO el broker chasqui.
+```
+
+## Estado (2026-05-31)
+
+### Hecho
+- Subsistema A (Brahman): matching tipado determinista (Exact/Structural/ExactThenStructural) + context biases + handshake Unix-socket (card-handshake/sidecar) + observabilidad admin (`brahman-status`) + UI probe (`chasqui-broker-explorer-llimphi`).
+- Subsistema B (Nouser): scanner + clustering `by_directory` + `MonadDb` (sled) + pseudo-embeddings 32d deterministas + real 384d ONNX gated por feature + contrato Nous (mock/real intercambiables) + UI explorer semántico + CLI `nouser`.
+- Nexo A↔B: los proveedores de embeddings se registran como Cards; el broker elige mock vs real por `BRAHMAN_BROKER_CONTEXT`.
+- Las dos UIs portadas a Llimphi (GPUI extinto) + menú principal/contextual (lotes 1 y 5).
+
+### Pendiente
+- Persistencia del broker: hoy vive en memoria del Init, sin snapshot/recover al reboot.
+- Transporte remoto: Brahman es Unix-socket local; el matching de módulos remotos (card-net/libp2p) ya NO está bloqueado por NAT — `card-net` cablea relay+dcutr+autonat (verificado por el test `jalar_a_traves_de_un_relay` de khipu). Lo que falta es CABLEAR el discovery: el daemon Nouser no llama `announce_outputs()` al DHT ni hay `find_remote_providers(flow_type)` en `card-sidecar`.
+- real-nous en producción: la feature `embeddings` arrastra ~200 MB de ONNX runtime (trade-off tamaño↔capacidad).
+- Integración a fondo cross-dominio (rimay/shuma/arje) con nouser, aún superficial.
+- La aspiración pub/sub original migró a Ayni (chat P2P); chasqui hoy NO transporta mensajes app↔app en tiempo real.
+
+## Estado vs aspiración
+
+```
+DEUDA / ASPIRA_A:
+  #1 PERSISTENCIA del broker: hoy vive en memoria del Init, sin snapshot/recover al reboot.
+  #2 TRANSPORTE remoto: Brahman es Unix-socket local; falta CABLEAR discovery por DHT
+     (announce_outputs en el daemon Nouser + find_remote_providers en card-sidecar).
+  #3 NAT traversal: HECHO en card-net (relay+dcutr+autonat), heredado por minga/agora/
+     chasqui/khipu; ya no bloquea el uso remoto P2P.
+  #4 real-nous en producción: feature `embeddings` arrastra ~200MB de ONNX runtime — trade-off size↔capacidad.
+  #5 COHERENCIA cross-dominio: rimay/shuma/arje aún no integrados a fondo con nouser.
+
+NORTE_ARQUITECTÓNICO:
+  Dos planos que convergen. PLANO DE TIPOS (Brahman): "decláme qué consumís y te encuentro quién lo produce,
+  determinista y observable, sin acoplar módulos". PLANO DE DATOS (Nouser): "dame una carpeta y te devuelvo su
+  estructura semántica latente (Mónadas) con embeddings intercambiables".
+  El destino es que el broker deje de ser local (Unix socket) y, sobre transporte minga/libp2p, haga discovery
+  tipado de pares REMOTOS — habilitando que Ayni (chat P2P) y otros descubran proveedores por tipo a través de la red,
+  no por config. Chasqui hoy es una joya quieta: matching + búsqueda + discovery local, lista para crecer a P2P.
+```
+
+---
+
+**Síntesis de una línea para otra IA:** chasqui es un dominio dual — (A) un **broker de tipos** Brahman que
+hace matching determinista y observable de flujos entre módulos (Cards) sin mover datos, y (B) **Nouser**, un motor de
+inteligencia de datos que agrupa archivos en Mónadas semánticas con embeddings intercambiables (mock 32d determinista /
+real 384d ONNX) descubiertos a través de (A) — cuya aspiración pub/sub original migró a Ayni y cuyo norte es elevar el
+discovery tipado local a discovery P2P remoto sobre transporte minga/libp2p.
diff --git a/02_ruway/chasqui/LEEME.md b/02_ruway/chasqui/LEEME.md
new file mode 100644
index 0000000..e86434b
--- /dev/null
+++ b/02_ruway/chasqui/LEEME.md
@@ -0,0 +1,41 @@
+# chasqui
+
+> `chasqui` (quechua: *mensajero del camino del inca*). Broker de mensajería + bus tipado.
+
+Sistema nervioso del monorepo. Apps publican y se suscriben a topics tipados; el broker rutea y persiste. Backend `nous` con dos implementaciones: `mock` (in-process para tests) y `real` (TCP + binary). Cada mensaje lleva su schema, fail-closed si el receptor no lo conoce.
+
+## Instalación
+
+```sh
+# arrancar el broker
+cargo run --release -p chasqui-broker
+
+# explorer (ver topics + mensajes en vivo)
+cargo run --release -p chasqui-broker-explorer-llimphi
+cargo run --release -p chasqui-explorer-llimphi
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — broker + clientes en Rust nativo.
+- **Wawa** — broker corre como app del kernel (`apps/`).
+- TCP localhost por default; sockets Unix opcionales.
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`chasqui-core`](chasqui-core/README.md) | Tipos: Topic, Message, Schema, Subscription. |
+| [`chasqui-broker`](chasqui-broker/README.md) | Binario del broker. |
+| [`chasqui-nous`](chasqui-nous/README.md) | Trait del transport. |
+| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | Transport in-process para tests. |
+| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Transport TCP/Unix binario. |
+| [`chasqui-card`](chasqui-card/README.md) | Card escritorio (estado del broker). |
+| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | UI: topics + suscriptores activos. |
+| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | UI: log de mensajes en vivo. |
+
+## Consideraciones
+
+- **Schema-first.** Sin schema declarado, ningún mensaje pasa.
+- **Persistencia opt-in** por topic; los topics efímeros viven sólo en memoria.
+- **No es Kafka.** Diseñado para el monorepo, no para volumen de producción interplanetaria.
diff --git a/02_ruway/chasqui/README.md b/02_ruway/chasqui/README.md
new file mode 100644
index 0000000..2366596
--- /dev/null
+++ b/02_ruway/chasqui/README.md
@@ -0,0 +1,38 @@
+# chasqui
+
+> `chasqui` (Quechua: *messenger of the Inca road*). Message broker + typed bus.
+
+Nervous system of the monorepo. Apps publish and subscribe to typed topics; the broker routes and persists. `nous` backend with two impls: `mock` (in-process for tests) and `real` (binary TCP). Every message carries its schema, fail-closed if the receiver doesn't know it.
+
+## Install
+
+```sh
+cargo run --release -p chasqui-broker
+cargo run --release -p chasqui-broker-explorer-llimphi
+cargo run --release -p chasqui-explorer-llimphi
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — broker + clients in native Rust.
+- **Wawa** — broker runs as a kernel app.
+- TCP localhost by default; Unix sockets optional.
+
+## Crates
+
+| Crate | Role |
+|---|---|
+| [`chasqui-core`](chasqui-core/README.md) | Topic, Message, Schema, Subscription. |
+| [`chasqui-broker`](chasqui-broker/README.md) | Broker binary. |
+| [`chasqui-nous`](chasqui-nous/README.md) | Transport trait. |
+| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | In-process transport. |
+| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Binary TCP/Unix transport. |
+| [`chasqui-card`](chasqui-card/README.md) | Desktop card. |
+| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Topics + active subscribers UI. |
+| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Live message log UI. |
+
+## Considerations
+
+- **Schema-first.** No schema declared, no message through.
+- **Persistence opt-in** per topic; ephemeral topics live in memory only.
+- **Not Kafka.** Designed for the monorepo, not interplanetary production volume.
diff --git a/02_ruway/chasqui/README.qu.md b/02_ruway/chasqui/README.qu.md
new file mode 100644
index 0000000..c06942d
--- /dev/null
+++ b/02_ruway/chasqui/README.qu.md
@@ -0,0 +1,40 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# chasqui
+
+> `chasqui` (runa-simi: *Inka ñanninpa mensahero*). Mensaje broker + tipo bus.
+
+Monorepupa sapan nervio. Aplikacionkuna tipasqa topics-pi publish + subscribe ruwanku; brokerqa rutéo + waqaychaq. `nous` backend, iskay implementaciones: `mock` (in-process tests-paq) + `real` (binario TCP). Sapanka mensaje schema-yuq, mana yachaqtin chaski-fail.
+
+## Churay
+
+```sh
+cargo run --release -p chasqui-broker
+cargo run --release -p chasqui-broker-explorer-llimphi
+cargo run --release -p chasqui-explorer-llimphi
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — broker + cliente Rust naturalwan.
+- **Wawa** — broker kernel apps hina.
+- TCP localhost default; Unix sockets opcional.
+
+## Crateskuna
+
+| Crate | Ima ruwan |
+|---|---|
+| [`chasqui-core`](chasqui-core/README.md) | Topic, Mensaje, Schema, Subscripción. |
+| [`chasqui-broker`](chasqui-broker/README.md) | Broker binario. |
+| [`chasqui-nous`](chasqui-nous/README.md) | Transporte trait. |
+| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | In-process transporte. |
+| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Binario TCP/Unix transporte. |
+| [`chasqui-card`](chasqui-card/README.md) | Escritorio card. |
+| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Topics + subscriptores UI. |
+| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Kawsaq mensaje log UI. |
+
+## Yuyaykunaq
+
+- **Schema-ñawpaq.** Mana schema mana mensaje.
+- **Waqaychay opt-in** topic-pi; ephemeris topics ukhupi kawsanku.
+- **Mana Kafka.** Monorepupaq, mana planetapaq.
diff --git a/02_ruway/chasqui/card-admin/Cargo.toml b/02_ruway/chasqui/card-admin/Cargo.toml
new file mode 100644
index 0000000..ffe3483
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "card-admin"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — admin API: snapshot del estado del broker (sesiones + matches) por Unix socket, format JSON."
+
+[dependencies]
+chasqui-broker = { path = "../chasqui-broker" }
+card-core = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+ulid = { workspace = true }
+anyhow = { workspace = true }
+
+[[example]]
+name = "brahman-status"
+path = "examples/brahman-status.rs"
diff --git a/02_ruway/chasqui/card-admin/examples/brahman-status.rs b/02_ruway/chasqui/card-admin/examples/brahman-status.rs
new file mode 100644
index 0000000..d6d58f2
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/examples/brahman-status.rs
@@ -0,0 +1,98 @@
+//! `brahman-status` — CLI para inspeccionar el estado del Init.
+//!
+//! Conecta al socket admin (default `$XDG_RUNTIME_DIR/brahman-admin.sock`,
+//! override con `$BRAHMAN_ADMIN_SOCKET`), recibe el snapshot, y lo imprime.
+
+use card_admin::{client, transport};
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    let path = transport::default_socket_path();
+    let snap = client::query(&path).await?;
+
+    println!(
+        "Init: server={} protocol={} attached={}",
+        snap.server_version, snap.protocol_version, snap.init_attached
+    );
+    if let Some(ctx) = &snap.current_context {
+        println!("Context: {}", ctx);
+    }
+    println!();
+    println!("Sessions ({}):", snap.sessions.len());
+    if snap.sessions.is_empty() {
+        println!("  (ninguna)");
+    } else {
+        for s in &snap.sessions {
+            let conscious_marker = if s.wit.is_some() { " 🧠" } else { "" };
+            let kind_marker = match s.kind {
+                card_core::CardKind::Ente => "ente",
+                card_core::CardKind::Data => "data",
+            };
+            println!(
+                "  [{}]  {}  {}{}  lifecycle={:?}  priority={:?}",
+                kind_marker, s.session, s.label, conscious_marker, s.lifecycle, s.priority
+            );
+            if let Some(sock) = &s.service_socket {
+                println!("      socket:     {}", sock.display());
+            }
+            for r in &s.references {
+                println!(
+                    "      ref {:?}  →  {}  ({})",
+                    r.kind, r.target_label, r.target_id
+                );
+            }
+            if let Some(data) = &s.data {
+                if !data.summary.is_empty() {
+                    println!("      summary:    {}", data.summary);
+                }
+                if data.member_count > 0 {
+                    println!(
+                        "      members:    {} (dispersion={:.2})",
+                        data.member_count, data.dispersion
+                    );
+                }
+                if !data.keywords.is_empty() {
+                    println!("      keywords:   {}", data.keywords.join(", "));
+                }
+                if !data.presentation_hint.is_empty() {
+                    println!("      lens hint:  {}", data.presentation_hint);
+                }
+            }
+            if let Some(wit) = &s.wit {
+                println!("      wit: {} / {}", wit.package, wit.world);
+                if !wit.imports.is_empty() {
+                    println!("           imports: {}", wit.imports.join(", "));
+                }
+                if !wit.exports.is_empty() {
+                    println!("           exports: {}", wit.exports.join(", "));
+                }
+            }
+            for f in &s.inputs {
+                println!("      in  {}: {:?}", f.name, f.ty);
+            }
+            for f in &s.outputs {
+                println!("      out {}: {:?}", f.name, f.ty);
+            }
+        }
+    }
+    println!();
+    println!("Matches ({}):", snap.matches.len());
+    if snap.matches.is_empty() {
+        println!("  (ninguno)");
+    } else {
+        for m in &snap.matches {
+            let pin_marker = if m.pinned { "📌" } else { "  " };
+            println!(
+                "  {} {}.{}  ←  {}.{}  via {:?}",
+                pin_marker,
+                m.consumer_label,
+                m.consumer.flow_name,
+                m.producer_label,
+                m.producer.flow_name,
+                m.via
+            );
+        }
+    }
+
+    Ok(())
+}
diff --git a/02_ruway/chasqui/card-admin/src/client.rs b/02_ruway/chasqui/card-admin/src/client.rs
new file mode 100644
index 0000000..40cba19
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/src/client.rs
@@ -0,0 +1,48 @@
+//! Cliente admin: lee un `StatusSnapshot` desde un socket admin.
+
+use std::path::Path;
+
+use thiserror::Error;
+use tokio::io::{AsyncBufReadExt, BufReader};
+use tokio::net::UnixStream;
+
+use crate::snapshot::StatusSnapshot;
+
+#[derive(Debug, Error)]
+pub enum AdminError {
+    #[error("E/S: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("respuesta vacía")]
+    Empty,
+    #[error("JSON inválido: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+/// Conecta al socket admin, lee la línea JSON y deserializa.
+pub async fn query(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
+    let stream = UnixStream::connect(path).await?;
+    let mut reader = BufReader::new(stream);
+    let mut line = String::new();
+    let n = reader.read_line(&mut line).await?;
+    if n == 0 {
+        return Err(AdminError::Empty);
+    }
+    let snapshot = serde_json::from_str(&line)?;
+    Ok(snapshot)
+}
+
+/// Variante sync de [`query`] para callers que no tienen runtime tokio
+/// (típicamente: GUIs con su propio executor, como GPUI).
+pub fn query_blocking(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
+    use std::io::{BufRead, BufReader as StdBufReader};
+    use std::os::unix::net::UnixStream as StdUnixStream;
+    let stream = StdUnixStream::connect(path)?;
+    let mut reader = StdBufReader::new(stream);
+    let mut line = String::new();
+    let n = reader.read_line(&mut line)?;
+    if n == 0 {
+        return Err(AdminError::Empty);
+    }
+    let snapshot = serde_json::from_str(&line)?;
+    Ok(snapshot)
+}
diff --git a/02_ruway/chasqui/card-admin/src/lib.rs b/02_ruway/chasqui/card-admin/src/lib.rs
new file mode 100644
index 0000000..1cb1d5b
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/src/lib.rs
@@ -0,0 +1,23 @@
+//! `brahman-admin` — observabilidad del broker.
+//!
+//! Expone un Unix socket separado (no se mezcla con el handshake) en el
+//! que cada conexión recibe un `StatusSnapshot` JSON y se cierra. Es
+//! single-shot por conexión: pensado para herramientas como
+//! `brahman-status`, dashboards y health-checks.
+//!
+//! Wire format: una línea JSON por conexión, terminada en `\n`. Esto
+//! hace trivial inspeccionar con `nc` o `socat` además del cliente
+//! tipado de este crate.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+pub mod client;
+pub mod server;
+pub mod snapshot;
+pub mod transport;
+
+pub use snapshot::StatusSnapshot;
+
+/// Versión del crate de admin.
+pub const ADMIN_VERSION: &str = env!("CARGO_PKG_VERSION");
diff --git a/02_ruway/chasqui/card-admin/src/server.rs b/02_ruway/chasqui/card-admin/src/server.rs
new file mode 100644
index 0000000..ebfddc5
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/src/server.rs
@@ -0,0 +1,110 @@
+//! Servidor admin: emite un `StatusSnapshot` JSON por conexión y cierra.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use chasqui_broker::Broker;
+use tokio::io::AsyncWriteExt;
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::Mutex;
+use tracing::warn;
+
+use crate::snapshot::StatusSnapshot;
+
+/// Configuración del servidor admin.
+#[derive(Debug, Clone, Default)]
+pub struct AdminConfig {
+    /// `true` si el Init está atado al servidor que aloja este admin.
+    pub init_attached: bool,
+    /// Contexto operativo del broker, espejado en el snapshot.
+    pub current_context: Option<String>,
+}
+
+/// Servidor admin escuchando en un Unix socket.
+pub struct AdminServer {
+    listener: UnixListener,
+    socket_path: PathBuf,
+    broker: Arc<Mutex<Broker>>,
+    config: AdminConfig,
+}
+
+impl AdminServer {
+    /// Crea el listener. Si `path` existe, lo elimina (asume socket stale).
+    pub fn bind(
+        path: impl Into<PathBuf>,
+        broker: Arc<Mutex<Broker>>,
+        config: AdminConfig,
+    ) -> std::io::Result<Self> {
+        let socket_path = path.into();
+        if socket_path.exists() {
+            std::fs::remove_file(&socket_path)?;
+        }
+        if let Some(parent) = socket_path.parent() {
+            if !parent.as_os_str().is_empty() {
+                std::fs::create_dir_all(parent)?;
+            }
+        }
+        let listener = UnixListener::bind(&socket_path)?;
+        Ok(Self {
+            listener,
+            socket_path,
+            broker,
+            config,
+        })
+    }
+
+    pub fn socket_path(&self) -> &Path {
+        &self.socket_path
+    }
+
+    /// Loop de aceptación: cada conexión recibe un snapshot y se cierra.
+    pub async fn run(self) -> std::io::Result<()> {
+        loop {
+            let (stream, _addr) = self.listener.accept().await?;
+            let broker = self.broker.clone();
+            let config = self.config.clone();
+            tokio::spawn(async move {
+                if let Err(e) = handle_conn(stream, broker, config).await {
+                    warn!(error = %e, "admin conn falló");
+                }
+            });
+        }
+    }
+}
+
+impl Drop for AdminServer {
+    fn drop(&mut self) {
+        if let Err(e) = std::fs::remove_file(&self.socket_path) {
+            if e.kind() != std::io::ErrorKind::NotFound {
+                warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar admin socket");
+            }
+        }
+    }
+}
+
+async fn handle_conn(
+    mut stream: UnixStream,
+    broker: Arc<Mutex<Broker>>,
+    config: AdminConfig,
+) -> std::io::Result<()> {
+    let snapshot = build_snapshot(&broker, &config).await;
+    let mut json = serde_json::to_string(&snapshot)?;
+    json.push('\n');
+    stream.write_all(json.as_bytes()).await?;
+    stream.shutdown().await?;
+    Ok(())
+}
+
+async fn build_snapshot(broker: &Arc<Mutex<Broker>>, config: &AdminConfig) -> StatusSnapshot {
+    let b = broker.lock().await;
+    let sessions: Vec<_> = b.cards().cloned().collect();
+    let matches = b.all_matches();
+    StatusSnapshot {
+        server_version: crate::ADMIN_VERSION.to_string(),
+        protocol_version: card_core::PROTOCOL_VERSION.to_string(),
+        init_attached: config.init_attached,
+        current_context: config.current_context.clone(),
+        sessions,
+        matches,
+    }
+}
diff --git a/02_ruway/chasqui/card-admin/src/snapshot.rs b/02_ruway/chasqui/card-admin/src/snapshot.rs
new file mode 100644
index 0000000..5503635
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/src/snapshot.rs
@@ -0,0 +1,24 @@
+//! Tipos del snapshot que el admin server emite.
+
+use chasqui_broker::{BrokeredCard, Match};
+use serde::{Deserialize, Serialize};
+
+/// Snapshot completo del estado del Init en un instante.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StatusSnapshot {
+    /// Versión del crate del Init que respondió.
+    pub server_version: String,
+    /// Versión del protocolo brahman.
+    pub protocol_version: String,
+    /// `true` si el Init está atado al servidor.
+    pub init_attached: bool,
+    /// Contexto operativo activo del broker (p. ej. `"test"`, `"prod"`).
+    /// `None` si no hay contexto configurado — los biases per-contexto
+    /// declarados en las Cards quedan inactivos.
+    #[serde(default)]
+    pub current_context: Option<String>,
+    /// Cards actualmente registradas (sesiones vivas).
+    pub sessions: Vec<BrokeredCard>,
+    /// Matches consumer↔producer derivados del set actual.
+    pub matches: Vec<Match>,
+}
diff --git a/02_ruway/chasqui/card-admin/src/transport.rs b/02_ruway/chasqui/card-admin/src/transport.rs
new file mode 100644
index 0000000..d2afed6
--- /dev/null
+++ b/02_ruway/chasqui/card-admin/src/transport.rs
@@ -0,0 +1,20 @@
+//! Convenciones de transporte para el socket admin.
+
+use std::path::PathBuf;
+
+/// Variable de entorno que sobreescribe la ruta del socket admin.
+pub const SOCKET_ENV: &str = "BRAHMAN_ADMIN_SOCKET";
+
+/// Nombre del socket admin dentro del runtime dir.
+pub const SOCKET_NAME: &str = "brahman-admin.sock";
+
+/// Ruta canónica al socket admin del Init.
+pub fn default_socket_path() -> PathBuf {
+    if let Ok(p) = std::env::var(SOCKET_ENV) {
+        return PathBuf::from(p);
+    }
+    let base = std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir);
+    base.join(SOCKET_NAME)
+}
diff --git a/02_ruway/chasqui/card-handshake/Cargo.toml b/02_ruway/chasqui/card-handshake/Cargo.toml
new file mode 100644
index 0000000..dbc93cc
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/Cargo.toml
@@ -0,0 +1,37 @@
+[package]
+name = "card-handshake"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (card-net)."
+
+[dependencies]
+card-core = { workspace = true }
+chasqui-broker = { path = "../chasqui-broker" }
+card-net = { workspace = true }
+blake3 = { workspace = true }
+futures = { workspace = true }
+notify = { workspace = true }
+serde = { workspace = true }
+postcard = { workspace = true }
+tokio = { workspace = true }
+tokio-util = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+tokio = { workspace = true }
+anyhow = { workspace = true }
+
+[[example]]
+name = "probe"
+path = "examples/probe.rs"
+
+[[example]]
+name = "subscriber"
+path = "examples/subscriber.rs"
diff --git a/02_ruway/chasqui/card-handshake/examples/probe.rs b/02_ruway/chasqui/card-handshake/examples/probe.rs
new file mode 100644
index 0000000..96f6904
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/examples/probe.rs
@@ -0,0 +1,51 @@
+//! probe — herramienta de diagnóstico del handshake.
+//!
+//! Conecta a un Init brahman vivo, hace handshake, un ping, y se va.
+//! Ruta del socket: `$BRAHMAN_INIT_SOCKET` o el default
+//! ([`card_handshake::transport::default_socket_path`]).
+//!
+//! Uso:
+//! ```sh
+//! cargo run -p brahman-handshake --example probe
+//! ```
+
+use std::collections::BTreeSet;
+
+use card_core::{Card, Payload, Supervision, CARD_SCHEMA_VERSION};
+use card_handshake::{client::Client, transport};
+use ulid::Ulid;
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    let card = Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: "brahman-probe".into(),
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        ..Default::default()
+    };
+
+    let path = transport::default_socket_path();
+    println!("connecting to {}", path.display());
+
+    let mut client = Client::connect(&path, card).await?;
+    let info = client.server_info();
+    println!(
+        "  HelloAck: session={} server={} protocol={} init_attached={}",
+        client.session(),
+        info.server_version,
+        info.protocol_version,
+        info.init_attached
+    );
+
+    let ts = client.ping().await?;
+    println!("  Pong: ts={}ms", ts);
+
+    client.farewell().await?;
+    println!("  Farewell OK");
+
+    Ok(())
+}
diff --git a/02_ruway/chasqui/card-handshake/examples/subscriber.rs b/02_ruway/chasqui/card-handshake/examples/subscriber.rs
new file mode 100644
index 0000000..b858170
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/examples/subscriber.rs
@@ -0,0 +1,83 @@
+//! `subscriber` — cliente brahman que loguea cada `MatchEvent` recibido.
+//!
+//! Declara una Card con un input `in` de tipo `json`. Cada vez que el
+//! broker matchea (o desmatch) ese input contra un productor, imprime
+//! una línea. Útil para visualizar la dinámica del broker en vivo.
+//!
+//! Uso:
+//! ```sh
+//! cargo run -p brahman-handshake --example subscriber [label]
+//! ```
+
+use std::collections::BTreeSet;
+use std::time::Duration;
+
+use card_core::{
+    ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
+    CARD_SCHEMA_VERSION,
+};
+use card_handshake::{client::Client, transport};
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+    let label = std::env::args()
+        .nth(1)
+        .unwrap_or_else(|| "subscriber".into());
+
+    let card = Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.clone(),
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        flow: Flows {
+            input: vec![Flow {
+                name: "in".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+            output: vec![],
+        },
+        ..Default::default()
+    };
+
+    let path = transport::default_socket_path();
+    eprintln!("[{label}] connecting to {}", path.display());
+    let mut client = Client::connect(&path, card).await?;
+    eprintln!(
+        "[{label}] attached: session={} init={}",
+        client.session(),
+        client.server_info().init_attached
+    );
+
+    // Loop: espera hasta 25s por un MatchEvent. Si timeout, ping para
+    // mantener la conexión viva.
+    loop {
+        match client.await_event(Duration::from_secs(25)).await? {
+            Some(ev) => {
+                eprintln!(
+                    "[{label}] {:?}  {}  ←  {}.{}  via={:?}{}",
+                    ev.kind,
+                    ev.consumer_flow,
+                    if ev.producer_label.is_empty() {
+                        "<none>"
+                    } else {
+                        &ev.producer_label
+                    },
+                    ev.producer_flow,
+                    ev.via,
+                    if ev.pinned { "  📌" } else { "" }
+                );
+            }
+            None => {
+                let _ts = client.ping().await?;
+            }
+        }
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/client.rs b/02_ruway/chasqui/card-handshake/src/client.rs
new file mode 100644
index 0000000..d93dd33
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/client.rs
@@ -0,0 +1,312 @@
+//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
+
+use std::collections::VecDeque;
+use std::path::Path;
+use std::time::Duration;
+
+use card_core::{Card, WitInterface, CARD_SCHEMA_VERSION};
+use card_net::Keypair;
+use thiserror::Error;
+use tokio::io::{AsyncRead, AsyncWrite};
+use tokio::net::UnixStream;
+
+use crate::codec::{read_frame, write_frame};
+use crate::identity::SessionCert;
+use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
+use crate::signature::{sign_hello, SignatureError};
+
+/// Errores del cliente.
+#[derive(Debug, Error)]
+pub enum ClientError {
+    #[error("E/S: {0}")]
+    Io(#[from] std::io::Error),
+
+    /// El servidor respondió con un error explícito.
+    #[error("servidor: {0}")]
+    Server(#[source] HandshakeError),
+
+    /// El servidor envió un frame que no esperábamos en este punto del protocolo.
+    #[error("frame inesperado: {got}")]
+    UnexpectedFrame { got: &'static str },
+
+    /// La Card que el cliente intentó enviar no pasa su propia validación.
+    #[error("card inválida pre-envío: {0}")]
+    InvalidCard(String),
+
+    /// Firma del Hello falló al construirse (rara — sólo puede pasar
+    /// si la keypair pasada está en un estado inválido).
+    #[error("firma del Hello falló: {0}")]
+    Signature(#[from] SignatureError),
+}
+
+/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
+/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
+/// request/response se buferean en `pending_events` y se obtienen vía
+/// [`Client::take_event`] o [`Client::await_event`].
+///
+/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
+/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
+/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
+/// `card_handshake::network`).
+#[derive(Debug)]
+pub struct Client<S = UnixStream> {
+    stream: S,
+    session: SessionId,
+    server_info: HelloAck,
+    pending_events: VecDeque<MatchEvent>,
+}
+
+impl Client<UnixStream> {
+    /// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
+    /// Equivalente a `connect_with(path, card, None)`.
+    pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
+        Self::connect_with(path, card, None).await
+    }
+
+    /// Conecta al socket Unix enviando Hello con la Card dada y
+    /// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
+    /// el server registra el módulo como "consciente".
+    pub async fn connect_with(
+        path: impl AsRef<Path>,
+        card: Card,
+        wit: Option<WitInterface>,
+    ) -> Result<Self, ClientError> {
+        let stream = UnixStream::connect(path).await?;
+        Self::connect_with_stream(stream, card, wit).await
+    }
+}
+
+impl<S> Client<S>
+where
+    S: AsyncRead + AsyncWrite + Unpin + Send,
+{
+    /// Constructor genérico sobre un stream ya abierto, **sin firma**.
+    /// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
+    /// o tests in-memory. Para libp2p remoto usá
+    /// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
+    /// el server libp2p rechaza Hello sin firma.
+    pub async fn connect_with_stream(
+        stream: S,
+        card: Card,
+        wit: Option<WitInterface>,
+    ) -> Result<Self, ClientError> {
+        Self::connect_inner(stream, card, wit, None, None).await
+    }
+
+    /// Igual que `connect_with_stream` pero firma el Hello con
+    /// `keypair`. Usar para conexiones libp2p donde el server exige
+    /// firma. La public key derivada de `keypair` debe coincidir con
+    /// el `peer_id` libp2p autenticado por Noise — típicamente la
+    /// keypair pasada a [`card_net::BrahmanNet::with_keypair`].
+    pub async fn connect_with_stream_signed(
+        stream: S,
+        card: Card,
+        wit: Option<WitInterface>,
+        keypair: &Keypair,
+    ) -> Result<Self, ClientError> {
+        Self::connect_inner(stream, card, wit, Some(keypair), None).await
+    }
+
+    /// Igual que `connect_with_stream_signed` pero además adjunta un
+    /// `SessionCert` que vincula la session keypair a una identity
+    /// master estable. El server, al recibir el cert, evalúa la
+    /// política de admisión contra el `master_peer_id` (no contra
+    /// el session peer_id) — permitiendo rotar la session sin perder
+    /// la identidad reconocida en allowlists remotas.
+    pub async fn connect_with_stream_signed_with_cert(
+        stream: S,
+        card: Card,
+        wit: Option<WitInterface>,
+        session_keypair: &Keypair,
+        identity_cert: SessionCert,
+    ) -> Result<Self, ClientError> {
+        Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
+    }
+
+    async fn connect_inner(
+        mut stream: S,
+        card: Card,
+        wit: Option<WitInterface>,
+        keypair: Option<&Keypair>,
+        identity_cert: Option<SessionCert>,
+    ) -> Result<Self, ClientError> {
+        card.validate()
+            .map_err(|e| ClientError::InvalidCard(e.to_string()))?;
+
+        let wire_card = card_core::WireCard::from(card);
+        let signature = match keypair {
+            Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?),
+            None => None,
+        };
+
+        let hello = Hello {
+            schema_version: CARD_SCHEMA_VERSION,
+            protocol_version: card_core::PROTOCOL_VERSION.to_string(),
+            card: wire_card,
+            wit,
+            signature,
+            identity_cert,
+        };
+        write_frame(&mut stream, &Frame::Hello(hello)).await?;
+
+        let frame = read_frame(&mut stream).await?;
+        let ack = match frame {
+            Frame::HelloAck(a) => a,
+            Frame::Error(e) => return Err(ClientError::Server(e)),
+            Frame::Hello(_) => return Err(ClientError::UnexpectedFrame { got: "Hello" }),
+            Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
+            Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
+            Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
+            Frame::MatchEvent(_) => {
+                return Err(ClientError::UnexpectedFrame {
+                    got: "MatchEvent (pre-handshake)",
+                });
+            }
+            Frame::ListSessions(_) => {
+                return Err(ClientError::UnexpectedFrame {
+                    got: "ListSessions (pre-handshake)",
+                });
+            }
+            Frame::SessionList(_) => {
+                return Err(ClientError::UnexpectedFrame {
+                    got: "SessionList (pre-handshake)",
+                });
+            }
+            Frame::ListMatches(_) => {
+                return Err(ClientError::UnexpectedFrame {
+                    got: "ListMatches (pre-handshake)",
+                });
+            }
+            Frame::MatchList(_) => {
+                return Err(ClientError::UnexpectedFrame {
+                    got: "MatchList (pre-handshake)",
+                });
+            }
+        };
+        Ok(Self {
+            stream,
+            session: ack.session,
+            server_info: ack,
+            pending_events: VecDeque::new(),
+        })
+    }
+
+    /// `SessionId` asignado por el servidor.
+    pub fn session(&self) -> SessionId {
+        self.session
+    }
+
+    /// Información del servidor recibida en el handshake.
+    pub fn server_info(&self) -> &HelloAck {
+        &self.server_info
+    }
+
+    /// Envía un Ping y devuelve el timestamp del servidor. Los frames
+    /// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
+    pub async fn ping(&mut self) -> Result<u64, ClientError> {
+        write_frame(
+            &mut self.stream,
+            &Frame::Ping(Ping {
+                session: self.session,
+            }),
+        )
+        .await?;
+        loop {
+            match read_frame(&mut self.stream).await? {
+                Frame::Pong(p) => return Ok(p.timestamp_ms),
+                Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
+                Frame::Error(e) => return Err(ClientError::Server(e)),
+                _ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
+            }
+        }
+    }
+
+    /// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
+    pub fn take_event(&mut self) -> Option<MatchEvent> {
+        self.pending_events.pop_front()
+    }
+
+    /// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
+    /// está vacío, lee del wire hasta el timeout. Otros frames recibidos
+    /// (Pong huérfano, Error) cortan la espera con error.
+    pub async fn await_event(
+        &mut self,
+        timeout: Duration,
+    ) -> Result<Option<MatchEvent>, ClientError> {
+        if let Some(ev) = self.pending_events.pop_front() {
+            return Ok(Some(ev));
+        }
+        match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
+            Err(_) => Ok(None),
+            Ok(Err(e)) => Err(ClientError::Io(e)),
+            Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
+            Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
+            Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
+                got: "non-event en await_event",
+            }),
+        }
+    }
+
+    /// Pide al servidor el listado de sesiones activas. Pensado para
+    /// observadores (broker-explorer, CLIs de diagnóstico). Como
+    /// `ping`, los `MatchEvent` que lleguen intercalados se bufean
+    /// en `pending_events` y no rompen la respuesta.
+    pub async fn list_sessions(&mut self) -> Result<crate::messages::SessionList, ClientError> {
+        write_frame(
+            &mut self.stream,
+            &Frame::ListSessions(crate::messages::ListSessions {
+                session: self.session,
+            }),
+        )
+        .await?;
+        loop {
+            match read_frame(&mut self.stream).await? {
+                Frame::SessionList(list) => return Ok(list),
+                Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
+                Frame::Error(e) => return Err(ClientError::Server(e)),
+                _ => {
+                    return Err(ClientError::UnexpectedFrame {
+                        got: "non-session-list",
+                    });
+                }
+            }
+        }
+    }
+
+    /// Pide al servidor el listado de matches actuales del broker
+    /// (consumer↔producer pares con tipo y estrategia). Mismo patrón
+    /// de drenado de `MatchEvent`s intermedios.
+    pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
+        write_frame(
+            &mut self.stream,
+            &Frame::ListMatches(crate::messages::ListMatches {
+                session: self.session,
+            }),
+        )
+        .await?;
+        loop {
+            match read_frame(&mut self.stream).await? {
+                Frame::MatchList(list) => return Ok(list),
+                Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
+                Frame::Error(e) => return Err(ClientError::Server(e)),
+                _ => {
+                    return Err(ClientError::UnexpectedFrame {
+                        got: "non-match-list",
+                    });
+                }
+            }
+        }
+    }
+
+    /// Cierre cooperativo. Consume el cliente.
+    pub async fn farewell(mut self) -> Result<(), ClientError> {
+        write_frame(
+            &mut self.stream,
+            &Frame::Farewell(Farewell {
+                session: self.session,
+            }),
+        )
+        .await?;
+        Ok(())
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/codec.rs b/02_ruway/chasqui/card-handshake/src/codec.rs
new file mode 100644
index 0000000..adfb038
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/codec.rs
@@ -0,0 +1,72 @@
+//! Codec de wire: frames length-prefixed con cuerpo postcard.
+//!
+//! Cada frame en el stream tiene la forma:
+//! ```text
+//! [4 bytes LE: longitud N] [N bytes: postcard(Frame)]
+//! ```
+//!
+//! El `MAX_FRAME_BYTES` evita que un cliente malicioso/buggy reserve memoria
+//! arbitraria al anunciar un length absurdo.
+
+use std::io::{Error, ErrorKind, Result};
+
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+
+use crate::messages::Frame;
+
+/// Tamaño máximo de un frame antes de que el reader rechace la conexión.
+/// 4 MiB cubre cualquier Card razonable con margen amplio.
+pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
+
+/// Escribe un frame al stream.
+pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Frame) -> Result<()> {
+    let bytes = postcard::to_allocvec(frame)
+        .map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard encode: {e}")))?;
+    if bytes.len() > MAX_FRAME_BYTES {
+        return Err(Error::new(
+            ErrorKind::InvalidData,
+            format!("frame demasiado grande: {} bytes", bytes.len()),
+        ));
+    }
+    let len = bytes.len() as u32;
+    w.write_all(&len.to_le_bytes()).await?;
+    w.write_all(&bytes).await?;
+    w.flush().await?;
+    Ok(())
+}
+
+/// Lee un frame del stream.
+pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Frame> {
+    let mut len_buf = [0u8; 4];
+    r.read_exact(&mut len_buf).await?;
+    let len = u32::from_le_bytes(len_buf) as usize;
+    if len > MAX_FRAME_BYTES {
+        return Err(Error::new(
+            ErrorKind::InvalidData,
+            format!("frame anunciado demasiado grande: {len} bytes"),
+        ));
+    }
+    let mut buf = vec![0u8; len];
+    r.read_exact(&mut buf).await?;
+    postcard::from_bytes(&buf)
+        .map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard decode: {e}")))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::messages::{Frame, HandshakeError};
+
+    #[tokio::test]
+    async fn frame_roundtrip() {
+        let frame = Frame::Error(HandshakeError::Rejected("test".into()));
+        let mut buf = Vec::new();
+        write_frame(&mut buf, &frame).await.unwrap();
+        let mut cursor = std::io::Cursor::new(buf);
+        let decoded = read_frame(&mut cursor).await.unwrap();
+        match decoded {
+            Frame::Error(HandshakeError::Rejected(s)) => assert_eq!(s, "test"),
+            _ => panic!("variant mismatch"),
+        }
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/identity.rs b/02_ruway/chasqui/card-handshake/src/identity.rs
new file mode 100644
index 0000000..ac2a46c
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/identity.rs
@@ -0,0 +1,358 @@
+//! Identidad multi-key del nodo: separación entre **identity** (master,
+//! persistente forever) y **session** (keypair libp2p efímera, rotable).
+//!
+//! ## Problema que resuelve
+//!
+//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
+//! keypair (por compromiso, por higiene, por cambio de hardware)
+//! cambiaba el peer_id, lo que invalidaba todas las allowlists
+//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
+//! coordinar.
+//!
+//! ## Modelo
+//!
+//! Cada nodo tiene **dos** keypairs Ed25519:
+//!
+//! - **Identity** (master): persistente para siempre. Identifica al
+//!   nodo como entidad lógica. Su `peer_id` es lo que va en
+//!   allowlists/denylists remotas.
+//! - **Session** (operacional): la que libp2p usa para Noise. Puede
+//!   rotarse libremente sin coordinar — el nodo emite un
+//!   [`SessionCert`] firmado con la identity que prueba "esta session
+//!   key pertenece a mí".
+//!
+//! ## Wire
+//!
+//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
+//! server valida:
+//! 1. La session key del cert == public key de `Hello.signature` ==
+//!    deriva al peer_id autenticado por Noise (consistencia interna).
+//! 2. La firma del cert verifica con la master pubkey declarada.
+//! 3. El cert no está expirado.
+//! 4. La política (allowlist/denylist) se evalúa contra
+//!    `master.to_peer_id()`, NO contra el session peer_id.
+//!
+//! Sin cert, el server cae al modelo de Fase 3: policy contra session
+//! peer_id (compat). Esto permite migración gradual.
+
+use std::sync::Arc;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use card_net::{Keypair, PeerId, PublicKey};
+use serde::{Deserialize, Serialize};
+
+/// TTL recomendado para un session cert: 24 horas. Suficiente para
+/// que un nodo "viva" un día sin re-emitir; corto enough para que
+/// un cert robado no sirva por mucho. Operadores con políticas
+/// estrictas pueden bajarlo; con uptime largo, subirlo.
+pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
+
+/// Identidad lógica del nodo. Wraps la master keypair y emite certs
+/// de session firmados.
+///
+/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
+/// Sólo se usa para firmar certs locales y para derivar
+/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
+/// session keypair.
+#[derive(Clone)]
+pub struct Identity {
+    master: Arc<Keypair>,
+}
+
+impl Identity {
+    /// Construye una Identity a partir de una keypair existente.
+    /// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
+    pub fn from_keypair(master: Keypair) -> Self {
+        Self {
+            master: Arc::new(master),
+        }
+    }
+
+    /// Variante para callers que ya tienen la keypair en `Arc`.
+    pub fn from_arc(master: Arc<Keypair>) -> Self {
+        Self { master }
+    }
+
+    /// PeerId derivado de la master pubkey. Ésta es la identidad
+    /// "lógica" estable del nodo — lo que va en allowlists/denylists.
+    pub fn master_peer_id(&self) -> PeerId {
+        self.master.public().to_peer_id()
+    }
+
+    /// Emite un [`SessionCert`] firmado: certifica que la session
+    /// keypair `session` pertenece a esta identity hasta `now + ttl`.
+    pub fn issue_session_cert(
+        &self,
+        session: &Keypair,
+        ttl: Duration,
+    ) -> Result<SessionCert, CertError> {
+        let now_ms = now_unix_ms();
+        let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
+        let session_pubkey = session.public().encode_protobuf();
+        let master_pubkey = self.master.public().encode_protobuf();
+        let payload = sign_payload(&session_pubkey, expires_at_ms);
+        let signature = self
+            .master
+            .sign(&payload)
+            .map_err(|e| CertError::Sign(e.to_string()))?;
+        Ok(SessionCert {
+            version: SESSION_CERT_VERSION,
+            session_pubkey,
+            master_pubkey,
+            expires_at_ms,
+            signature,
+        })
+    }
+}
+
+impl std::fmt::Debug for Identity {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Identity")
+            .field("master_peer_id", &self.master_peer_id())
+            .finish()
+    }
+}
+
+/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
+/// el shape de `SessionCert`.
+pub const SESSION_CERT_VERSION: u8 = 1;
+
+/// Certificado firmado por la identity que vincula una session key
+/// libp2p a la identidad master del nodo, con expiración.
+///
+/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
+/// format canónico libp2p (`encode_protobuf`) — mismo encoding que
+/// `HelloSignature.public_key`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct SessionCert {
+    /// Versión del esquema (ver `SESSION_CERT_VERSION`).
+    pub version: u8,
+    /// Public key de la session libp2p (la que firma el Hello), en
+    /// format libp2p protobuf.
+    pub session_pubkey: Vec<u8>,
+    /// Public key de la master identity, en format libp2p protobuf.
+    /// El verificador deriva el `master_peer_id` desde acá.
+    pub master_pubkey: Vec<u8>,
+    /// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
+    /// cert no es válido y el nodo debe re-emitirse uno nuevo
+    /// (rotando o re-firmando la misma session).
+    pub expires_at_ms: u64,
+    /// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
+    pub signature: Vec<u8>,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum CertError {
+    #[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
+    UnknownVersion(u8),
+    #[error("decode master_pubkey: {0}")]
+    DecodeMaster(String),
+    #[error("decode session_pubkey: {0}")]
+    DecodeSession(String),
+    #[error("firma del cert inválida")]
+    InvalidSignature,
+    #[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
+    Expired { expires: u64, now: u64 },
+    #[error("session_pubkey del cert no coincide con la del Hello.signature")]
+    SessionMismatch,
+    #[error("error al firmar: {0}")]
+    Sign(String),
+}
+
+impl SessionCert {
+    /// Verifica el cert: versión, firma criptográfica, no expiración.
+    /// Devuelve el `(master_peer_id, session_peer_id)` derivados.
+    ///
+    /// El caller debe además chequear que `session_peer_id` coincide
+    /// con el peer_id autenticado por Noise (lo verifica
+    /// [`verify_against_session`]).
+    pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
+        if self.version != SESSION_CERT_VERSION {
+            return Err(CertError::UnknownVersion(self.version));
+        }
+        let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
+            .map_err(|e| CertError::DecodeMaster(e.to_string()))?;
+        let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
+            .map_err(|e| CertError::DecodeSession(e.to_string()))?;
+        let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
+        if !master_pk.verify(&payload, &self.signature) {
+            return Err(CertError::InvalidSignature);
+        }
+        let now = now_unix_ms();
+        if now >= self.expires_at_ms {
+            return Err(CertError::Expired {
+                expires: self.expires_at_ms,
+                now,
+            });
+        }
+        Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
+    }
+
+    /// Verifica el cert Y exige que su `session_pubkey` matchee a
+    /// `expected_session_pubkey` (la que firmó el Hello). Esto
+    /// previene que un atacante reutilice un cert válido con una
+    /// session key distinta.
+    ///
+    /// Devuelve el `master_peer_id` derivado, que es el que el server
+    /// debe usar para evaluar la política de admisión.
+    pub fn verify_against_session(
+        &self,
+        expected_session_pubkey: &[u8],
+    ) -> Result<PeerId, CertError> {
+        if self.session_pubkey.as_slice() != expected_session_pubkey {
+            return Err(CertError::SessionMismatch);
+        }
+        let (master_peer, _session_peer) = self.verify()?;
+        Ok(master_peer)
+    }
+}
+
+/// Concat canónico de los campos firmados. Cualquier cambio aquí
+/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
+fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
+    let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
+    buf.push(SESSION_CERT_VERSION);
+    buf.extend_from_slice(b"sess");
+    buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
+    buf.extend_from_slice(session_pubkey);
+    buf.extend_from_slice(&expires_at_ms.to_le_bytes());
+    buf
+}
+
+fn now_unix_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn issue_and_verify_cert() {
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let cert = id
+            .issue_session_cert(&session, DEFAULT_SESSION_TTL)
+            .unwrap();
+        let (master_peer, session_peer) = cert.verify().unwrap();
+        assert_eq!(master_peer, id.master_peer_id());
+        assert_eq!(session_peer, session.public().to_peer_id());
+    }
+
+    #[test]
+    fn verify_against_session_admits_matching() {
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let cert = id
+            .issue_session_cert(&session, DEFAULT_SESSION_TTL)
+            .unwrap();
+        let session_pk = session.public().encode_protobuf();
+        let master_peer = cert.verify_against_session(&session_pk).unwrap();
+        assert_eq!(master_peer, id.master_peer_id());
+    }
+
+    #[test]
+    fn verify_against_session_rejects_mismatch() {
+        let master = Keypair::generate_ed25519();
+        let session_a = Keypair::generate_ed25519();
+        let session_b = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let cert = id
+            .issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
+            .unwrap();
+        let other_pk = session_b.public().encode_protobuf();
+        let err = cert.verify_against_session(&other_pk).unwrap_err();
+        assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
+    }
+
+    #[test]
+    fn cert_with_zero_ttl_is_expired() {
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let cert = id
+            .issue_session_cert(&session, Duration::from_secs(0))
+            .unwrap();
+        // Pequeña espera para asegurar que now_ms > expires_at_ms.
+        std::thread::sleep(Duration::from_millis(5));
+        let err = cert.verify().unwrap_err();
+        assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
+    }
+
+    #[test]
+    fn tampered_signature_rejected() {
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let mut cert = id
+            .issue_session_cert(&session, DEFAULT_SESSION_TTL)
+            .unwrap();
+        if let Some(b) = cert.signature.last_mut() {
+            *b ^= 0x01;
+        }
+        let err = cert.verify().unwrap_err();
+        assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
+    }
+
+    #[test]
+    fn tampered_expires_at_rejected() {
+        // Si alguien extiende el expires_at sin re-firmar, la firma
+        // no cuadra → InvalidSignature.
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let mut cert = id
+            .issue_session_cert(&session, DEFAULT_SESSION_TTL)
+            .unwrap();
+        cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
+        let err = cert.verify().unwrap_err();
+        assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
+    }
+
+    #[test]
+    fn unknown_version_rejected() {
+        let master = Keypair::generate_ed25519();
+        let session = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let mut cert = id
+            .issue_session_cert(&session, DEFAULT_SESSION_TTL)
+            .unwrap();
+        cert.version = 99;
+        let err = cert.verify().unwrap_err();
+        assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
+    }
+
+    #[test]
+    fn rotated_session_with_same_master_yields_same_master_peer_id() {
+        // La propiedad fundamental: rotar la session key NO cambia el
+        // master_peer_id derivado del cert.
+        let master = Keypair::generate_ed25519();
+        let id = Identity::from_keypair(master);
+        let original_master_peer = id.master_peer_id();
+
+        let session1 = Keypair::generate_ed25519();
+        let cert1 = id
+            .issue_session_cert(&session1, DEFAULT_SESSION_TTL)
+            .unwrap();
+        let (master_from_cert1, _) = cert1.verify().unwrap();
+
+        // Rotar: nueva session keypair, mismo master.
+        let session2 = Keypair::generate_ed25519();
+        let cert2 = id
+            .issue_session_cert(&session2, DEFAULT_SESSION_TTL)
+            .unwrap();
+        let (master_from_cert2, _) = cert2.verify().unwrap();
+
+        assert_eq!(master_from_cert1, original_master_peer);
+        assert_eq!(master_from_cert2, original_master_peer);
+        assert_eq!(
+            master_from_cert1, master_from_cert2,
+            "rotar session NO debe cambiar el master_peer_id"
+        );
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/lib.rs b/02_ruway/chasqui/card-handshake/src/lib.rs
new file mode 100644
index 0000000..c744925
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/lib.rs
@@ -0,0 +1,33 @@
+//! `brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
+//!
+//! Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
+//! lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
+//! que son los módulos Brahman. Cada conexión arranca con un `Hello` que
+//! lleva una [`card_core::Card`]; el servidor valida la Card, deriva el
+//! [`TrustLevel`], emite un `HelloAck` con `session-id` ULID, y a partir de
+//! ahí acepta `Ping`/`Farewell`.
+//!
+//! Wire format: frames length-prefixed (4 bytes LE) con cuerpo
+//! [`postcard`]-codificado. Compacto, rápido y reversible.
+//!
+//! Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
+//! implementación nativa Rust↔Rust que cubre el caso común antes de que los
+//! módulos WASM consuman el mismo contrato vía ABI generada.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+pub mod codec;
+pub mod identity;
+pub mod messages;
+pub mod server;
+pub mod client;
+pub mod network;
+pub mod peer_policy;
+pub mod signature;
+pub mod transport;
+
+pub use card_core::PROTOCOL_VERSION;
+
+/// Versión del crate de handshake (independiente de `PROTOCOL_VERSION`).
+pub const HANDSHAKE_VERSION: &str = env!("CARGO_PKG_VERSION");
diff --git a/02_ruway/chasqui/card-handshake/src/messages.rs b/02_ruway/chasqui/card-handshake/src/messages.rs
new file mode 100644
index 0000000..d1f47e7
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/messages.rs
@@ -0,0 +1,234 @@
+//! Mensajes del protocolo de handshake.
+//!
+//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
+
+use std::path::PathBuf;
+
+use chasqui_broker::MatchStrategy;
+use card_core::{TypeRef, WireCard, WitInterface};
+use serde::{Deserialize, Serialize};
+use ulid::Ulid;
+
+/// Identificador de sesión emitido por el servidor en `HelloAck`.
+pub type SessionId = Ulid;
+
+/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
+/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
+/// la convierte a `Card` para uso interno. Opcionalmente, una
+/// `WitInterface` ya extraída — si está presente, el módulo es
+/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`.
+///
+/// **Firma (Fase 3, trust remoto)**: el campo `signature` es
+/// obligatorio para conexiones libp2p (donde el server exige que la
+/// public key derive al `peer_id` autenticado por Noise) y opcional
+/// para Unix socket (donde SO_PEERCRED del kernel ya provee
+/// autenticación). La firma cubre los bytes postcard de
+/// `(WireCard, Option<WitInterface>)` — ver
+/// [`HelloSignature::sign_payload`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Hello {
+    /// Versión del schema de Card que el cliente sigue.
+    pub schema_version: u16,
+    /// Versión del protocolo handshake del cliente.
+    pub protocol_version: String,
+    /// Tarjeta de Presentación, proyectada al wire.
+    pub card: WireCard,
+    /// Interfaz WIT extraída por el cliente (típicamente con
+    /// `brahman-card-wit`). `None` si el módulo es agnóstico.
+    #[serde(default)]
+    pub wit: Option<WitInterface>,
+    /// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones
+    /// remotas (libp2p); opcional para Unix socket. Ver módulo
+    /// [`super::signature`] para construcción y verificación.
+    #[serde(default)]
+    pub signature: Option<HelloSignature>,
+    /// Cert opcional que vincula la session keypair (la que firma el
+    /// Hello) a una **identity master** estable. Si está presente,
+    /// la política de admisión se evalúa contra el `master_peer_id`
+    /// derivado del cert — no contra el session peer_id. Esto permite
+    /// rotar la session sin invalidar las allowlists remotas.
+    ///
+    /// Ver [`super::identity::SessionCert`] para shape y semantics.
+    /// Si es `None`, fallback al modelo de Fase 3: la política
+    /// evalúa el session peer_id directamente.
+    #[serde(default)]
+    pub identity_cert: Option<crate::identity::SessionCert>,
+}
+
+/// Firma de un Hello. La `public_key` viaja en el format canónico
+/// libp2p (protobuf) — el verificador la decodifica y compara su
+/// `peer_id` derivado con la identidad libp2p autenticada por Noise.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct HelloSignature {
+    /// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`.
+    pub public_key: Vec<u8>,
+    /// Bytes de la firma Ed25519 sobre el payload canonical.
+    pub signature: Vec<u8>,
+}
+
+/// Respuesta del servidor a un `Hello` aceptado.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HelloAck {
+    /// Versión del crate del servidor.
+    pub server_version: String,
+    /// Versión del protocolo soportada por el servidor.
+    pub protocol_version: String,
+    /// Identificador de sesión asignado.
+    pub session: SessionId,
+    /// `true` si el Init está vinculado al servidor; `false` si el servidor
+    /// corre standalone (modo degradado).
+    pub init_attached: bool,
+}
+
+/// Latido del cliente. El servidor responde con [`Pong`] llevando su reloj.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Ping {
+    pub session: SessionId,
+}
+
+/// Respuesta a un `Ping` con timestamp del servidor (ms desde UNIX_EPOCH).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Pong {
+    pub timestamp_ms: u64,
+}
+
+/// Cierre cooperativo de la sesión por parte del cliente.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Farewell {
+    pub session: SessionId,
+}
+
+/// Errores del protocolo emitidos por el servidor.
+#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
+pub enum HandshakeError {
+    #[error("protocolo incompatible: {0}")]
+    ProtocolMismatch(String),
+    #[error("card inválida: {0}")]
+    InvalidCard(String),
+    #[error("schema de card incompatible: cliente={client}, servidor={server}")]
+    SchemaMismatch { client: u16, server: u16 },
+    #[error("sin autorización: {0}")]
+    Unauthorized(String),
+    #[error("capacidad requerida no satisfecha: {0}")]
+    CapabilityUnmet(String),
+    #[error("rechazado: {0}")]
+    Rejected(String),
+    #[error("error interno: {0}")]
+    Internal(String),
+}
+
+/// Notificación push del server al consumer: un match disponible o perdido.
+///
+/// El server emite `Available` cuando un productor empieza a satisfacer un
+/// `flow.input` del consumer (ya sea porque el productor acaba de
+/// registrarse, o porque cambió el match anterior). Emite `Lost` cuando
+/// el productor previo dejó de satisfacer el input (desregistro o
+/// cambio de match).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MatchEvent {
+    pub kind: MatchEventKind,
+    /// Nombre del input del consumer al que aplica el evento.
+    pub consumer_flow: String,
+    /// Sesión y label del productor (en `Lost` puede ser nil/vacío).
+    pub producer_session: SessionId,
+    pub producer_label: String,
+    pub producer_flow: String,
+    /// Tipo del flujo matcheado.
+    pub ty: TypeRef,
+    /// Estrategia que ganó (relevante en `Available`).
+    pub via: MatchStrategy,
+    /// `true` si fue resuelto por `pin_to`.
+    pub pinned: bool,
+    /// Socket de servicio (data plane) que declaró el productor.
+    /// Si está presente, el consumer puede conectar directo sin
+    /// pasar por discovery adicional. `None` si el productor no
+    /// declaró service_socket en su Card.
+    #[serde(default)]
+    pub producer_service_socket: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum MatchEventKind {
+    Available,
+    Lost,
+}
+
+/// Pedido de listado de sesiones activas registradas en el broker. La
+/// `session` es el id propio del que pregunta — el server lo valida
+/// contra la sesión actual de la conexión, mismo patrón que `Ping`.
+///
+/// Pensado para herramientas de observabilidad (broker-explorer y
+/// CLIs de diagnóstico). No expone secrets: sólo metadata pública
+/// que el módulo ya anunció en su `Hello`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListSessions {
+    pub session: SessionId,
+}
+
+/// Una entrada en la respuesta a `ListSessions`. Slim por diseño —
+/// el observer arma la UI con esto sin tener que abrir conexiones
+/// adicionales por sesión.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionEntry {
+    pub session: SessionId,
+    /// Label declarado en `WireCard.label` — el "nombre humano" del
+    /// módulo.
+    pub label: String,
+    /// Versión del schema de Card que el módulo declaró.
+    pub schema_version: u16,
+    /// Nombres de los `flow.output` que la Card declara producir.
+    pub outputs: Vec<String>,
+    /// Nombres de los `flow.input` que la Card declara consumir.
+    pub inputs: Vec<String>,
+    /// `true` si el módulo se anunció como "consciente" (trajo
+    /// `WitInterface` extraída en el Hello).
+    pub conscious: bool,
+}
+
+/// Respuesta a `ListSessions`. El orden no está garantizado — los
+/// clientes que necesiten estabilidad pueden ordenar por `session`
+/// (Ulid es ordenable temporal).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionList {
+    pub entries: Vec<SessionEntry>,
+}
+
+/// Pedido del listado de matches actuales del broker. La `session`
+/// se valida igual que `ListSessions`. Si el server no tiene broker
+/// configurado, devuelve la lista vacía (no es un error — refleja
+/// que no hay matching activo).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListMatches {
+    pub session: SessionId,
+}
+
+/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
+/// actualmente computados por el broker.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MatchList {
+    pub matches: Vec<chasqui_broker::Match>,
+}
+
+/// Frame único de wire — discriminada por variante. Cada conexión es un
+/// stream de frames.
+///
+/// Direcciones:
+/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
+///   `ListMatches`.
+/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
+///   `SessionList`, `MatchList`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Frame {
+    Hello(Hello),
+    HelloAck(HelloAck),
+    Ping(Ping),
+    Pong(Pong),
+    Farewell(Farewell),
+    Error(HandshakeError),
+    MatchEvent(MatchEvent),
+    ListSessions(ListSessions),
+    SessionList(SessionList),
+    ListMatches(ListMatches),
+    MatchList(MatchList),
+}
diff --git a/02_ruway/chasqui/card-handshake/src/network.rs b/02_ruway/chasqui/card-handshake/src/network.rs
new file mode 100644
index 0000000..66ab228
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/network.rs
@@ -0,0 +1,289 @@
+//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
+//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
+//! length-prefixed) ahora también viaja sobre streams libp2p de la
+//! malla `brahman-net`.
+//!
+//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
+//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
+//! mismo `Server` que ya escucha por Unix socket — la `Session` es
+//! genérica sobre el transporte, así que ambas vías comparten broker,
+//! tablas de sesiones, push de MatchEvents, todo.
+//!
+//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
+//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
+//! cualquier `Client`.
+//!
+//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
+//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
+//! Hello, como en el path Unix. Trust remoto (verificación de firma
+//! antes de aceptar el Hello) es Fase 3.
+//!
+//! Ejemplo (servidor — Arje):
+//! ```ignore
+//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
+//! let net = Arc::new(BrahmanNet::new()?);
+//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
+//!
+//! tokio::spawn(card_handshake::network::run_libp2p_accept_loop(
+//!     server.clone(),
+//!     net.clone(),
+//! ));
+//! // Server::run sigue escuchando Unix en paralelo.
+//! ```
+//!
+//! Ejemplo (cliente — sidecar de un Ente remoto):
+//! ```ignore
+//! let net = BrahmanNet::new()?;
+//! net.dial(remote_multiaddr);
+//! let mut client = card_handshake::network::connect_libp2p(
+//!     &net, peer_id, my_card, None,
+//! ).await?;
+//! client.ping().await?;
+//! ```
+
+use std::sync::Arc;
+
+use card_core::{Card, TypeRef, WitInterface};
+use card_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
+
+use crate::identity::SessionCert;
+use futures::StreamExt;
+use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
+use tracing::{debug, warn};
+
+use crate::client::{Client, ClientError};
+use crate::server::Server;
+
+/// Sub-protocolo del handshake brahman sobre streams libp2p.
+pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
+    StreamProtocol::new("/brahman/handshake/1.0.0");
+
+/// Tipo del stream que ve la lógica del handshake una vez convertido
+/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
+/// `tokio::io::AsyncRead/Write` (resto del crate).
+pub type LibP2pHandshakeStream = Compat<Stream>;
+
+/// Errores específicos del backend libp2p.
+#[derive(Debug, thiserror::Error)]
+pub enum NetworkError {
+    #[error("abrir stream libp2p: {0}")]
+    OpenStream(#[from] OpenStreamError),
+
+    #[error("handshake: {0}")]
+    Handshake(#[from] ClientError),
+
+    #[error("aceptar stream libp2p: {0}")]
+    AcceptStream(String),
+}
+
+/// Loop de aceptación de streams libp2p del protocolo handshake.
+/// Cada stream entrante se construye como `Session` reutilizando las
+/// tablas compartidas del `Server`, así que conviven con sesiones
+/// Unix indistinguibles.
+///
+/// Vive hasta que el control libp2p se cierre o el caller drop el
+/// future. Errores por sesión se loggean (no tumban el loop).
+pub async fn run_libp2p_accept_loop(
+    server: Arc<Server>,
+    net: Arc<BrahmanNet>,
+) -> Result<(), NetworkError> {
+    let mut control = net.control.clone();
+    let mut incoming = control
+        .accept(BRAHMAN_HANDSHAKE_PROTOCOL)
+        .map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
+
+    while let Some((peer, stream)) = incoming.next().await {
+        let server = server.clone();
+        // .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
+        // y capturamos `Compat<Stream>` en la closure, el future
+        // resultante hereda traits que dyn AsyncReadWrite no satisface
+        // (compatibility con thread-safety de tokio::spawn).
+        tokio::spawn(handle_libp2p_session(server, stream, peer));
+    }
+
+    Ok(())
+}
+
+async fn handle_libp2p_session(
+    server: Arc<Server>,
+    stream: Stream,
+    peer: PeerId,
+) {
+    // session_from_libp2p_stream propaga el peer_id al `do_handshake`,
+    // que exige firma del Hello cuya public key derive a este peer.
+    let session = server.session_from_libp2p_stream(stream.compat(), peer);
+    if let Err(e) = session.handle().await {
+        warn!(
+            target: "card_handshake::network",
+            peer = %peer,
+            error = %e,
+            "sesión libp2p terminó con error"
+        );
+    }
+}
+
+/// Conecta como cliente a un Ente remoto vía libp2p y completa el
+/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga
+/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace
+/// `net.dial(multiaddr)` antes.
+///
+/// La `keypair` debe ser la misma que la del nodo libp2p (la que
+/// pasaste a [`card_net::BrahmanNet::with_keypair`]). Si no coincide
+/// con el `peer_id` autenticado por Noise, el server rechaza el Hello
+/// con `Unauthorized`.
+///
+/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
+/// `farewell` funcionan idéntico al path Unix. El stream subyacente
+/// es libp2p convertido vía `tokio_util::compat`.
+pub async fn connect_libp2p(
+    net: &BrahmanNet,
+    peer: PeerId,
+    card: Card,
+    wit: Option<WitInterface>,
+    keypair: &Keypair,
+) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
+    let mut control = net.control.clone();
+    let stream = control
+        .open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
+        .await?;
+    let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?;
+    Ok(client)
+}
+
+/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
+/// El server, al verificar el cert, evalúa la política de admisión
+/// contra el `master_peer_id` derivado — no contra el `peer_id`
+/// libp2p. Esto permite **rotar** la session keypair sin perder
+/// reconocimiento en allowlists remotas.
+///
+/// El `keypair` debe ser la session libp2p (la que firma la conexión
+/// Noise); el `cert` debe haber sido emitido por una identity master
+/// para esa misma session pubkey (ver
+/// [`crate::identity::Identity::issue_session_cert`]).
+pub async fn connect_libp2p_with_cert(
+    net: &BrahmanNet,
+    peer: PeerId,
+    card: Card,
+    wit: Option<WitInterface>,
+    session_keypair: &Keypair,
+    cert: SessionCert,
+) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
+    let mut control = net.control.clone();
+    let stream = control
+        .open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
+        .await?;
+    let client = Client::connect_with_stream_signed_with_cert(
+        stream.compat(),
+        card,
+        wit,
+        session_keypair,
+        cert,
+    )
+    .await?;
+    Ok(client)
+}
+
+// =====================================================================
+// Discovery remoto via DHT — Fase 2
+// =====================================================================
+//
+// Cuando un Ente registra una Card con outputs en el Init local, el
+// Init anuncia al DHT (`net.start_providing(key)`) bajo una key
+// derivada de `(flow_name, TypeRef)`. Cualquier nodo conectado al
+// mismo DHT puede consultar `find_remote_providers(flow_name, type)`
+// y obtener la lista de `PeerId`s que dijeron proveer ese flow.
+//
+// La key es **estable y libre de colisiones** entre versiones del
+// monorepo: usa blake3 sobre un canon textual `brahman-flow|{name}|{type_canon}`.
+// Cambiar la canonicalización rompe el discovery cross-version, así
+// que cualquier modificación requiere bump de versión documentado.
+
+/// Prefijo de namespace para todas las keys DHT del subprotocolo
+/// brahman. Discrimina contra otros usos del mismo DHT (sync minga,
+/// futuros) — protege contra colisiones accidentales.
+const FLOW_KEY_PREFIX: &str = "brahman-flow|v1|";
+
+/// Canonicaliza un `TypeRef` a string estable. Cambios aquí rompen
+/// la compatibilidad de discovery cross-version; bump documentado
+/// en `FLOW_KEY_PREFIX` al modificar.
+fn canonicalize_type(t: &TypeRef) -> String {
+    match t {
+        TypeRef::Primitive { name } => format!("prim:{}", name),
+        TypeRef::Wit {
+            package,
+            interface,
+            name,
+        } => format!(
+            "wit:{}#{}#{}",
+            package,
+            interface.as_deref().unwrap_or(""),
+            name
+        ),
+    }
+}
+
+/// Deriva la key del DHT para un `(flow_name, type_ref)` específico.
+/// blake3-32B determinístico — la misma tupla en cualquier máquina
+/// produce la misma key.
+pub fn flow_dht_key(flow_name: &str, type_ref: &TypeRef) -> [u8; 32] {
+    let canon = format!(
+        "{}{}|{}",
+        FLOW_KEY_PREFIX,
+        flow_name,
+        canonicalize_type(type_ref)
+    );
+    *blake3::hash(canon.as_bytes()).as_bytes()
+}
+
+/// Anuncia al DHT que este nodo provee cada output flow declarado
+/// en `card`. Llamarlo tras `register_session` propaga la
+/// disponibilidad a todos los peers que comparten DHT con éste.
+///
+/// Idempotente: re-anunciar la misma key actualiza el TTL del record
+/// en el DHT. Best-effort: si `start_providing` falla por falta de
+/// peers cercanos (DHT vacío), el record vive en el store local
+/// hasta que llegue una conexión.
+pub fn announce_outputs(net: &BrahmanNet, card: &Card) {
+    for flow in &card.flow.output {
+        let key = flow_dht_key(&flow.name, &flow.ty);
+        debug!(
+            target: "card_handshake::network",
+            flow = %flow.name,
+            "announce_output → DHT"
+        );
+        net.start_providing(&key);
+    }
+}
+
+/// Retira los anuncios DHT previos de [`announce_outputs`] para esta
+/// `card`. Llamado desde `cleanup` cuando una sesión cierra (Farewell,
+/// EOF, error). El record local se borra al instante; copias
+/// replicadas en peers remotos expiran por TTL natural de kad.
+pub fn withdraw_outputs(net: &BrahmanNet, card: &Card) {
+    for flow in &card.flow.output {
+        let key = flow_dht_key(&flow.name, &flow.ty);
+        debug!(
+            target: "card_handshake::network",
+            flow = %flow.name,
+            "withdraw_output → DHT (stop_providing)"
+        );
+        net.stop_providing(&key);
+    }
+}
+
+/// Consulta el DHT por peers que han anunciado proveer el flow
+/// `(flow_name, type_ref)`. Devuelve la lista resuelta de `PeerId`s.
+/// Lista vacía si nadie anuncia, si la query timeout-ea, o si el
+/// DHT no ha encontrado providers.
+///
+/// Para cada `PeerId` devuelto, el caller puede luego dial-ar al
+/// peer (a sus addrs conocidas vía Identify) y abrir un sub-handshake
+/// remoto con [`connect_libp2p`].
+pub async fn find_remote_providers(
+    net: &BrahmanNet,
+    flow_name: &str,
+    type_ref: &TypeRef,
+) -> Vec<PeerId> {
+    let key = flow_dht_key(flow_name, type_ref);
+    net.find_providers(&key).await
+}
diff --git a/02_ruway/chasqui/card-handshake/src/peer_policy.rs b/02_ruway/chasqui/card-handshake/src/peer_policy.rs
new file mode 100644
index 0000000..54a81a9
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/peer_policy.rs
@@ -0,0 +1,582 @@
+//! Política de admisión de peers libp2p: allowlist + denylist con hot
+//! reload opcional.
+//!
+//! Capa de política sobre el trust criptográfico de Fase 3. Combina:
+//!
+//! - **Denylist**: peers explícitamente baneados. Si está, deny gana.
+//! - **Allowlist**: si está set, sólo los peers listados pasan.
+//!   Si no está set, modo abierto (todo peer Ed25519-válido pasa,
+//!   sujeto sólo a denylist).
+//!
+//! Sin denylist y sin allowlist → modo totalmente abierto (compat
+//! con todo lo anterior). Con allowlist y denylist a la vez, el
+//! orden de evaluación es: deny first → allow check → admit.
+//!
+//! Aplica únicamente al path libp2p — el path Unix usa SO_PEERCRED
+//! del kernel para autenticación local, no PeerId.
+//!
+//! ## Hot reload
+//!
+//! Si la política se construyó con [`PeerPolicy::watch_files`], un
+//! thread dedicado vigila los archivos de allow/deny vía `notify`.
+//! Cualquier cambio (write, create, modify, remove) dispara una
+//! recarga atómica con debounce de 250ms (los editores típicos
+//! producen varios eventos por save).
+//!
+//! Errores de reload (parse fallido, archivo eliminado) se loggean
+//! pero NO bajan la política existente — aceptamos la versión
+//! anterior hasta que el archivo vuelva a parsearse limpio. Esto
+//! evita que un error de tipeo deje al Init en modo inseguro.
+//!
+//! ## Formato del archivo
+//!
+//! Idéntico para allow y deny: PeerId base58 por línea, `#` para
+//! comentarios (línea entera o inline), líneas vacías ignoradas.
+
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
+use std::time::{Duration, Instant};
+
+use card_net::{BrahmanNet, PeerId};
+use tracing::{debug, info, warn};
+
+/// Política de admisión combinada (allow + deny). Clone barato (todos
+/// los campos son Arc o referencias inmutables).
+#[derive(Clone)]
+pub struct PeerPolicy {
+    inner: Arc<RwLock<PolicyInner>>,
+    paths: Arc<PolicyPaths>,
+    /// `BrahmanNet` opcional asociado vía [`Self::attach_to_net`].
+    /// Si está set, cada cambio en la denylist se sincroniza con el
+    /// `block_list` behaviour del swarm — los peers baneados son
+    /// rechazados ANTES del Noise handshake. `RwLock<Option<...>>`
+    /// para que `attach_to_net` se pueda llamar después del
+    /// constructor (típico en ente-zero: primero arma la policy,
+    /// después el net, después attach).
+    net: Arc<RwLock<Option<Arc<BrahmanNet>>>>,
+}
+
+#[derive(Default)]
+struct PolicyInner {
+    /// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto.
+    allow: Option<BTreeSet<PeerId>>,
+    /// Peers baneados. Vacío = sin denylist.
+    deny: BTreeSet<PeerId>,
+}
+
+#[derive(Default)]
+struct PolicyPaths {
+    allow_path: Option<PathBuf>,
+    deny_path: Option<PathBuf>,
+}
+
+/// Decisión del gate de política para un peer dado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Decision {
+    /// El peer es admitido (no está en deny y, si hay allow, está en allow).
+    Admit,
+    /// El peer está explícitamente en la denylist.
+    DeniedByDenylist,
+    /// Hay allowlist configurada y el peer no está en ella.
+    NotInAllowlist,
+}
+
+impl Decision {
+    pub fn is_admitted(self) -> bool {
+        matches!(self, Decision::Admit)
+    }
+
+    pub fn reason(self) -> &'static str {
+        match self {
+            Decision::Admit => "admit",
+            Decision::DeniedByDenylist => "explicitly denied",
+            Decision::NotInAllowlist => "not in allowlist",
+        }
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum PolicyError {
+    #[error("leer política en {path}: {source}")]
+    Io {
+        path: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+    #[error("línea {line_no} de {path}: PeerId inválido '{value}'")]
+    InvalidPeerId {
+        path: PathBuf,
+        line_no: usize,
+        value: String,
+    },
+}
+
+impl PeerPolicy {
+    /// Política totalmente abierta: todo peer pasa. Útil como default
+    /// cuando no hay archivos configurados.
+    pub fn open() -> Self {
+        Self {
+            inner: Arc::new(RwLock::new(PolicyInner::default())),
+            paths: Arc::new(PolicyPaths::default()),
+            net: Arc::new(RwLock::new(None)),
+        }
+    }
+
+    /// Construye una política inline con sets explícitos. Sin paths
+    /// asociados, así que `reload` y `watch_files` son no-ops.
+    pub fn from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>) -> Self {
+        Self {
+            inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
+            paths: Arc::new(PolicyPaths::default()),
+            net: Arc::new(RwLock::new(None)),
+        }
+    }
+
+    /// Carga política desde archivos. Cada path es opcional: `None`
+    /// significa "esa lista no aplica" (allow=None ⇒ modo abierto;
+    /// deny=None ⇒ sin baneados). Asocia los paths internamente para
+    /// que `reload` y `watch_files` los re-lean.
+    pub fn from_files(
+        allow_path: Option<&Path>,
+        deny_path: Option<&Path>,
+    ) -> Result<Self, PolicyError> {
+        let allow = match allow_path {
+            Some(p) => Some(parse_peer_set(p)?),
+            None => None,
+        };
+        let deny = match deny_path {
+            Some(p) => parse_peer_set(p)?,
+            None => BTreeSet::new(),
+        };
+        Ok(Self {
+            inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
+            paths: Arc::new(PolicyPaths {
+                allow_path: allow_path.map(Path::to_path_buf),
+                deny_path: deny_path.map(Path::to_path_buf),
+            }),
+            net: Arc::new(RwLock::new(None)),
+        })
+    }
+
+    /// Evalúa si `peer` puede registrarse. Toma read lock — barato,
+    /// concurrente, sin awaits.
+    pub fn evaluate(&self, peer: &PeerId) -> Decision {
+        let inner = match self.inner.read() {
+            Ok(g) => g,
+            Err(_) => {
+                // Lock envenenado: degrada a "deny por seguridad".
+                warn!("policy lock envenenado — deny por defecto");
+                return Decision::DeniedByDenylist;
+            }
+        };
+        if inner.deny.contains(peer) {
+            return Decision::DeniedByDenylist;
+        }
+        if let Some(allow) = &inner.allow {
+            if !allow.contains(peer) {
+                return Decision::NotInAllowlist;
+            }
+        }
+        Decision::Admit
+    }
+
+    /// Tamaño actual de cada lista, para logging. Tupla `(allow_count,
+    /// deny_count)`. `allow_count = None` significa "modo abierto"
+    /// (sin allowlist).
+    pub fn sizes(&self) -> (Option<usize>, usize) {
+        match self.inner.read() {
+            Ok(g) => (g.allow.as_ref().map(|s| s.len()), g.deny.len()),
+            Err(_) => (Some(0), 0),
+        }
+    }
+
+    /// Recarga atómica desde los paths asociados. Si un archivo
+    /// falla, la versión anterior persiste y el error se devuelve.
+    /// Esto evita que un typo en el archivo deje al Init en modo
+    /// inseguro.
+    ///
+    /// Si hay un `BrahmanNet` attached vía [`Self::attach_to_net`],
+    /// el cambio de denylist se sincroniza con el `block_list` del
+    /// swarm: se calcula el diff (added/removed) y se aplican
+    /// `block_peer`/`unblock_peer` por cada cambio.
+    pub fn reload(&self) -> Result<(), PolicyError> {
+        let new_allow = match &self.paths.allow_path {
+            Some(p) => Some(parse_peer_set(p)?),
+            None => None,
+        };
+        let new_deny = match &self.paths.deny_path {
+            Some(p) => parse_peer_set(p)?,
+            None => BTreeSet::new(),
+        };
+        // Snapshot de la deny actual ANTES de mutar, para diff.
+        let prev_deny = self
+            .inner
+            .read()
+            .map(|g| g.deny.clone())
+            .unwrap_or_default();
+        if let Ok(mut inner) = self.inner.write() {
+            inner.allow = new_allow;
+            inner.deny = new_deny.clone();
+        }
+        self.sync_deny_to_swarm(&prev_deny, &new_deny);
+        Ok(())
+    }
+
+    /// Asocia esta política a un `BrahmanNet`. Sincroniza el snapshot
+    /// actual de la denylist con el `block_list` behaviour del swarm
+    /// (cada peer baneado se rechaza ANTES del Noise handshake), y
+    /// registra el net para re-sincronizarse en cada [`Self::reload`].
+    ///
+    /// Si ya había un net attached, se reemplaza (caso esperado:
+    /// un Init no debería tener dos `BrahmanNet`s).
+    pub fn attach_to_net(&self, net: Arc<BrahmanNet>) {
+        // Sync inicial: bloquear todos los peers actualmente en deny.
+        if let Ok(inner) = self.inner.read() {
+            for peer in &inner.deny {
+                net.block_peer(*peer);
+            }
+        }
+        if let Ok(mut slot) = self.net.write() {
+            *slot = Some(net);
+        }
+    }
+
+    /// Calcula el diff entre `prev` y `new` y aplica
+    /// `block_peer`/`unblock_peer` al net asociado (si hay).
+    /// No-op si no hay net attached.
+    fn sync_deny_to_swarm(&self, prev: &BTreeSet<PeerId>, new: &BTreeSet<PeerId>) {
+        let net = match self.net.read() {
+            Ok(g) => match g.as_ref() {
+                Some(n) => n.clone(),
+                None => return,
+            },
+            Err(_) => return,
+        };
+        for added in new.difference(prev) {
+            net.block_peer(*added);
+        }
+        for removed in prev.difference(new) {
+            net.unblock_peer(*removed);
+        }
+    }
+
+    /// Arranca un thread que vigila los archivos asociados con
+    /// `notify` y llama [`Self::reload`] cuando cambian. Debounce
+    /// 250ms para coalescer múltiples eventos por save (los editores
+    /// hacen Create+Modify+más).
+    ///
+    /// Devuelve un `JoinHandle` que el caller debe mantener vivo.
+    /// Drop del handle no detiene el thread (notify watcher es
+    /// sticky); para detener, terminar el proceso.
+    ///
+    /// No-op si no hay paths asociados (devuelve un handle dummy
+    /// que termina inmediatamente).
+    pub fn spawn_watcher(&self) -> std::io::Result<std::thread::JoinHandle<()>> {
+        let allow_path = self.paths.allow_path.clone();
+        let deny_path = self.paths.deny_path.clone();
+        let policy = self.clone();
+
+        if allow_path.is_none() && deny_path.is_none() {
+            // Sin archivos a vigilar: spawn un thread que termina ya.
+            return std::thread::Builder::new()
+                .name("brahman-policy-watcher-noop".into())
+                .spawn(|| {});
+        }
+
+        std::thread::Builder::new()
+            .name("brahman-policy-watcher".into())
+            .spawn(move || {
+                run_watcher(policy, allow_path, deny_path);
+            })
+    }
+}
+
+impl std::fmt::Debug for PeerPolicy {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let (allow, deny) = self.sizes();
+        f.debug_struct("PeerPolicy")
+            .field("allow", &allow)
+            .field("deny", &deny)
+            .field("allow_path", &self.paths.allow_path)
+            .field("deny_path", &self.paths.deny_path)
+            .finish()
+    }
+}
+
+fn parse_peer_set(path: &Path) -> Result<BTreeSet<PeerId>, PolicyError> {
+    let contents = std::fs::read_to_string(path).map_err(|e| PolicyError::Io {
+        path: path.to_path_buf(),
+        source: e,
+    })?;
+    let mut out = BTreeSet::new();
+    for (idx, raw) in contents.lines().enumerate() {
+        let line_no = idx + 1;
+        let trimmed = raw.split('#').next().unwrap_or("").trim();
+        if trimmed.is_empty() {
+            continue;
+        }
+        let peer = trimmed
+            .parse::<PeerId>()
+            .map_err(|_| PolicyError::InvalidPeerId {
+                path: path.to_path_buf(),
+                line_no,
+                value: trimmed.to_string(),
+            })?;
+        out.insert(peer);
+    }
+    Ok(out)
+}
+
+const DEBOUNCE_MS: u64 = 250;
+
+fn run_watcher(
+    policy: PeerPolicy,
+    allow_path: Option<PathBuf>,
+    deny_path: Option<PathBuf>,
+) {
+    use notify::{RecursiveMode, Watcher};
+
+    let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
+    let mut watcher = match notify::recommended_watcher(move |res| {
+        let _ = tx.send(res);
+    }) {
+        Ok(w) => w,
+        Err(e) => {
+            warn!(?e, "notify watcher para policy no se pudo crear — hot reload deshabilitado");
+            return;
+        }
+    };
+
+    // Vigilamos los DIRECTORIOS de los archivos, no los archivos
+    // directos. Los editores típicos hacen rename-and-replace (escriben
+    // a tmp, rename al destino), lo que rompe el watch del archivo
+    // pero NO el del directorio. Trade-off: recibimos más eventos
+    // (cualquier archivo del dir), filtramos por path al procesar.
+    for p in [&allow_path, &deny_path].iter().filter_map(|x| x.as_ref()) {
+        if let Some(parent) = p.parent() {
+            if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) {
+                warn!(path = %parent.display(), ?e, "watch failed");
+            }
+        }
+    }
+
+    let debounce = Duration::from_millis(DEBOUNCE_MS);
+    let mut pending_at: Option<Instant> = None;
+
+    loop {
+        let timeout = match pending_at {
+            Some(at) => debounce.saturating_sub(at.elapsed()).max(Duration::from_millis(10)),
+            None => Duration::from_secs(60), // wakeup periódico, no esencial
+        };
+
+        match rx.recv_timeout(timeout) {
+            Ok(Ok(event)) => {
+                // Sólo nos interesan eventos sobre los paths exactos.
+                let touches_us = event.paths.iter().any(|p| {
+                    Some(p) == allow_path.as_ref() || Some(p) == deny_path.as_ref()
+                });
+                if !touches_us {
+                    continue;
+                }
+                debug!(?event.kind, "policy file event recibido — debounce");
+                pending_at = Some(Instant::now());
+            }
+            Ok(Err(e)) => {
+                warn!(?e, "notify error en policy watcher");
+            }
+            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
+                if let Some(at) = pending_at {
+                    if at.elapsed() >= debounce {
+                        match policy.reload() {
+                            Ok(()) => {
+                                let (a, d) = policy.sizes();
+                                info!(
+                                    allow = ?a,
+                                    deny = d,
+                                    "policy hot-reload completo"
+                                );
+                            }
+                            Err(e) => {
+                                warn!(?e, "policy hot-reload falló — manteniendo versión anterior");
+                            }
+                        }
+                        pending_at = None;
+                    }
+                }
+            }
+            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
+                warn!("policy watcher channel cerrado — terminando thread");
+                return;
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use card_net::Keypair;
+    use tempfile::TempDir;
+
+    fn fresh_peer() -> PeerId {
+        Keypair::generate_ed25519().public().to_peer_id()
+    }
+
+    #[test]
+    fn open_admits_anyone() {
+        let p = PeerPolicy::open();
+        assert_eq!(p.evaluate(&fresh_peer()), Decision::Admit);
+    }
+
+    #[test]
+    fn allow_only_admits_listed() {
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let policy = PeerPolicy::from_sets(
+            Some([p1].into_iter().collect()),
+            BTreeSet::new(),
+        );
+        assert_eq!(policy.evaluate(&p1), Decision::Admit);
+        assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
+    }
+
+    #[test]
+    fn deny_overrides_open() {
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let policy = PeerPolicy::from_sets(None, [p1].into_iter().collect());
+        assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
+        assert_eq!(policy.evaluate(&p2), Decision::Admit);
+    }
+
+    #[test]
+    fn deny_overrides_allow() {
+        // Conflicto explícito: p1 está en ambas. Deny gana.
+        let p1 = fresh_peer();
+        let policy = PeerPolicy::from_sets(
+            Some([p1].into_iter().collect()),
+            [p1].into_iter().collect(),
+        );
+        assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
+    }
+
+    #[test]
+    fn from_files_with_both_lists() {
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let p3 = fresh_peer();
+        let tmp = TempDir::new().unwrap();
+        let allow = tmp.path().join("allow.txt");
+        let deny = tmp.path().join("deny.txt");
+        std::fs::write(&allow, format!("{}\n{}\n", p1, p2)).unwrap();
+        std::fs::write(&deny, format!("# baneado\n{}\n", p2)).unwrap();
+        let policy = PeerPolicy::from_files(Some(&allow), Some(&deny)).unwrap();
+        assert_eq!(policy.evaluate(&p1), Decision::Admit);
+        assert_eq!(policy.evaluate(&p2), Decision::DeniedByDenylist); // deny gana
+        assert_eq!(policy.evaluate(&p3), Decision::NotInAllowlist);
+    }
+
+    #[test]
+    fn from_files_only_deny() {
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let tmp = TempDir::new().unwrap();
+        let deny = tmp.path().join("deny.txt");
+        std::fs::write(&deny, format!("{}\n", p1)).unwrap();
+        let policy = PeerPolicy::from_files(None, Some(&deny)).unwrap();
+        assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
+        assert_eq!(policy.evaluate(&p2), Decision::Admit);
+    }
+
+    #[test]
+    fn reload_picks_up_changes() {
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let tmp = TempDir::new().unwrap();
+        let allow = tmp.path().join("allow.txt");
+        std::fs::write(&allow, format!("{}\n", p1)).unwrap();
+
+        let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
+        assert_eq!(policy.evaluate(&p1), Decision::Admit);
+        assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
+
+        // Mutar el archivo: ahora p2 está, p1 no.
+        std::fs::write(&allow, format!("{}\n", p2)).unwrap();
+        policy.reload().unwrap();
+        assert_eq!(policy.evaluate(&p1), Decision::NotInAllowlist);
+        assert_eq!(policy.evaluate(&p2), Decision::Admit);
+    }
+
+    #[test]
+    fn reload_failure_preserves_previous_state() {
+        let p1 = fresh_peer();
+        let tmp = TempDir::new().unwrap();
+        let allow = tmp.path().join("allow.txt");
+        std::fs::write(&allow, format!("{}\n", p1)).unwrap();
+        let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
+        assert_eq!(policy.evaluate(&p1), Decision::Admit);
+
+        // Romper el archivo con basura.
+        std::fs::write(&allow, "this-is-not-a-peer-id\n").unwrap();
+        let err = policy.reload();
+        assert!(err.is_err(), "reload con typo debe fallar");
+
+        // Estado anterior se mantiene.
+        assert_eq!(
+            policy.evaluate(&p1),
+            Decision::Admit,
+            "policy debe conservar la versión anterior tras fallo de reload"
+        );
+    }
+
+    #[test]
+    fn invalid_file_rejected_at_load() {
+        let tmp = TempDir::new().unwrap();
+        let path = tmp.path().join("bad.txt");
+        std::fs::write(&path, "not-a-peer-id\n").unwrap();
+        let err = PeerPolicy::from_files(Some(&path), None).unwrap_err();
+        assert!(matches!(err, PolicyError::InvalidPeerId { .. }));
+    }
+
+    #[test]
+    fn watcher_reloads_on_file_change() {
+        // Test integración del watcher: arma policy con file, spawn
+        // watcher, modifica el archivo, espera el debounce, verifica
+        // que la policy refleja el cambio.
+        let p1 = fresh_peer();
+        let p2 = fresh_peer();
+        let tmp = TempDir::new().unwrap();
+        let allow = tmp.path().join("allow.txt");
+        std::fs::write(&allow, format!("{}\n", p1)).unwrap();
+
+        let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
+        let _watcher = policy.spawn_watcher().unwrap();
+
+        // Le damos un instante al watcher para subscribirse al dir.
+        std::thread::sleep(Duration::from_millis(100));
+
+        // Mutamos el archivo: p2 reemplaza a p1.
+        std::fs::write(&allow, format!("{}\n", p2)).unwrap();
+
+        // Esperamos > debounce + margen.
+        let deadline = Instant::now() + Duration::from_secs(3);
+        while Instant::now() < deadline {
+            if policy.evaluate(&p2) == Decision::Admit {
+                break;
+            }
+            std::thread::sleep(Duration::from_millis(50));
+        }
+
+        assert_eq!(
+            policy.evaluate(&p2),
+            Decision::Admit,
+            "watcher debería haber recargado la policy"
+        );
+        assert_eq!(
+            policy.evaluate(&p1),
+            Decision::NotInAllowlist,
+            "p1 debería haber salido tras el reload"
+        );
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/server.rs b/02_ruway/chasqui/card-handshake/src/server.rs
new file mode 100644
index 0000000..b39d813
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/server.rs
@@ -0,0 +1,855 @@
+//! Servidor de handshake. Listener Unix socket → sesiones por conexión.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use chasqui_broker::{Broker, Endpoint};
+use card_core::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
+use card_net::{BrahmanNet, PeerId};
+use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::{mpsc, Mutex};
+use tracing::{debug, warn};
+use ulid::Ulid;
+
+use crate::codec::{read_frame, write_frame};
+use crate::messages::{
+    Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, MatchEventKind, Ping, Pong,
+    SessionId,
+};
+
+/// Tabla de sesiones vivas indexada por `SessionId`.
+pub type SessionRegistry = Arc<Mutex<HashMap<SessionId, ResolvedCard>>>;
+
+/// Broker compartido (opcional) que el servidor mantiene en sincronía con
+/// el ciclo de vida de las sesiones.
+pub type SharedBroker = Arc<Mutex<Broker>>;
+
+/// Tabla de canales push por sesión: el server inyecta frames hacia el
+/// cliente (p. ej. `MatchEvent`) sin requerir que el cliente haga request.
+type SessionTxTable = Arc<Mutex<HashMap<SessionId, mpsc::Sender<Frame>>>>;
+
+/// Por sesión, último match conocido por nombre de input. Se usa para
+/// emitir diffs (Available/Lost) en lugar del estado completo.
+type LastMatches = Arc<Mutex<HashMap<SessionId, HashMap<String, Endpoint>>>>;
+
+/// Capacidad del canal push por sesión. Si se llena (cliente lento), los
+/// eventos extra se descartan — el cliente puede re-consultar el estado.
+const PUSH_CHANNEL_CAPACITY: usize = 32;
+
+/// Configuración del servidor.
+#[derive(Clone, Default)]
+pub struct ServerConfig {
+    /// `true` si el Init está atado al servidor (se reporta en `HelloAck`).
+    pub init_attached: bool,
+    /// Broker compartido. Si está presente, el servidor llama
+    /// `register` tras un Hello aceptado y `unregister` al cerrar la
+    /// sesión (Farewell o EOF). Si es `None`, el broker no se usa.
+    pub broker: Option<SharedBroker>,
+    /// Capa P2P compartida. Si está presente, cada Card registrada
+    /// con outputs se anuncia automáticamente al DHT vía
+    /// [`card_handshake::network::announce_outputs`], permitiendo
+    /// que un consumer remoto los descubra con
+    /// [`card_handshake::network::find_remote_providers`]. Si es
+    /// `None`, el server queda "ciego al DHT" — sólo matchea sesiones
+    /// locales (lo cual es correcto cuando no hay conectividad o no
+    /// se desea exponer al exterior).
+    pub net: Option<Arc<BrahmanNet>>,
+    /// Política de admisión de peers libp2p (allow + deny + hot
+    /// reload opcional). Si está presente, el trust gate del path
+    /// libp2p evalúa cada `peer_id` (ya autenticado por Noise)
+    /// contra esta política. `None` → modo totalmente abierto
+    /// (cualquier peer Ed25519-válido pasa). El path Unix la ignora.
+    pub policy: Option<crate::peer_policy::PeerPolicy>,
+}
+
+// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm
+// no es Debug). Sólo loggeamos los campos relevantes para tracing.
+impl std::fmt::Debug for ServerConfig {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ServerConfig")
+            .field("init_attached", &self.init_attached)
+            .field("broker", &self.broker.as_ref().map(|_| "<broker>"))
+            .field("net", &self.net.as_ref().map(|_| "<net>"))
+            .field("policy", &self.policy.as_ref().map(|p| p.sizes()))
+            .finish()
+    }
+}
+
+/// Servidor de handshake escuchando en un Unix socket.
+pub struct Server {
+    listener: UnixListener,
+    socket_path: PathBuf,
+    sessions: SessionRegistry,
+    push_table: SessionTxTable,
+    last_matches: LastMatches,
+    config: ServerConfig,
+}
+
+impl Server {
+    /// Crea el listener en `path`. Si el archivo existe, lo elimina (asume
+    /// que es un socket stale de una sesión previa).
+    pub fn bind(path: impl Into<PathBuf>, config: ServerConfig) -> std::io::Result<Self> {
+        let socket_path = path.into();
+        if socket_path.exists() {
+            std::fs::remove_file(&socket_path)?;
+        }
+        if let Some(parent) = socket_path.parent() {
+            if !parent.as_os_str().is_empty() {
+                std::fs::create_dir_all(parent)?;
+            }
+        }
+        let listener = UnixListener::bind(&socket_path)?;
+        Ok(Self {
+            listener,
+            socket_path,
+            sessions: Arc::new(Mutex::new(HashMap::new())),
+            push_table: Arc::new(Mutex::new(HashMap::new())),
+            last_matches: Arc::new(Mutex::new(HashMap::new())),
+            config,
+        })
+    }
+
+    /// Devuelve la ruta del socket (útil para clientes en el mismo proceso).
+    pub fn socket_path(&self) -> &Path {
+        &self.socket_path
+    }
+
+    /// Vista compartida del registro de sesiones — útil para el Init/Admin
+    /// para inspeccionar quién está conectado.
+    pub fn sessions(&self) -> SessionRegistry {
+        self.sessions.clone()
+    }
+
+    /// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
+    /// No corre el handler — eso es responsabilidad del llamante.
+    /// Path Unix → `expected_peer = None` (firma del Hello opcional;
+    /// SO_PEERCRED del kernel ya autentica al cliente).
+    pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
+        let (stream, _addr) = self.listener.accept().await?;
+        Ok(self.session_from_stream(stream))
+    }
+
+    /// Construye una `Session` a partir de un stream arbitrario que
+    /// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path
+    /// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer`
+    /// queda en `None`, así que la firma del Hello es opcional.
+    pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
+    where
+        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+    {
+        self.session_from_stream_inner(stream, None)
+    }
+
+    /// Variante para conexiones libp2p: el `peer_id` viene autenticado
+    /// por Noise. La sesión exige firma del Hello cuya public key
+    /// derive a este `peer_id` exacto. Ver
+    /// [`super::network::run_libp2p_accept_loop`].
+    pub fn session_from_libp2p_stream<S>(
+        &self,
+        stream: S,
+        peer: PeerId,
+    ) -> Session<S>
+    where
+        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+    {
+        self.session_from_stream_inner(stream, Some(peer))
+    }
+
+    fn session_from_stream_inner<S>(
+        &self,
+        stream: S,
+        expected_peer: Option<PeerId>,
+    ) -> Session<S>
+    where
+        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+    {
+        Session {
+            stream,
+            sessions: self.sessions.clone(),
+            push_table: self.push_table.clone(),
+            last_matches: self.last_matches.clone(),
+            config: self.config.clone(),
+            expected_peer,
+        }
+    }
+
+    /// Loop de aceptación: cada conexión se despacha en una task separada.
+    /// Vive hasta que el listener falle o el caller drop el future.
+    pub async fn run(self) -> std::io::Result<()> {
+        loop {
+            let session = self.accept_one().await?;
+            tokio::spawn(async move {
+                if let Err(e) = session.handle().await {
+                    warn!(error = %e, "session terminó con error");
+                }
+            });
+        }
+    }
+}
+
+impl Drop for Server {
+    fn drop(&mut self) {
+        // Limpieza best-effort del socket. Si falla, log y seguir.
+        if let Err(e) = std::fs::remove_file(&self.socket_path) {
+            if e.kind() != std::io::ErrorKind::NotFound {
+                warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar socket");
+            }
+        }
+    }
+}
+
+/// Conexión individual aceptada por el servidor. Genérica sobre el
+/// transport — funciona indistinguiblemente sobre `UnixStream` (modo
+/// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
+/// duplex (tests), etc.
+pub struct Session<S> {
+    stream: S,
+    sessions: SessionRegistry,
+    push_table: SessionTxTable,
+    last_matches: LastMatches,
+    config: ServerConfig,
+    /// Si está set, el path es libp2p y `do_handshake` exige firma
+    /// del Hello cuya public key derive a este `peer_id`. Si es
+    /// `None`, el path es Unix/in-memory y la firma es opcional
+    /// (pero si está, se verifica anyway por defensa en profundidad).
+    expected_peer: Option<PeerId>,
+}
+
+impl<S> Session<S>
+where
+    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+{
+    /// Procesa la conexión hasta `Farewell` o EOF.
+    ///
+    /// Estructura: handshake (sobre el stream entero) → split en
+    /// halves (read + write) → reader loop principal + writer task
+    /// que drena el push channel. Garantiza cleanup (sessions + broker
+    /// + canales) sin importar la rama de salida.
+    ///
+    /// El split es necesario para soportar streams `!Sync` como
+    /// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
+    /// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
+    /// su propia task, eliminando el requirement y permitiendo que
+    /// la misma `Session` sirva indistinta sobre Unix socket o
+    /// stream libp2p remoto.
+    pub async fn handle(self) -> std::io::Result<()> {
+        let Self {
+            mut stream,
+            sessions,
+            push_table,
+            last_matches,
+            config,
+            expected_peer,
+        } = self;
+
+        let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await?
+        {
+            Some(id) => id,
+            None => return Ok(()), // Hello rechazado, no se registró nada
+        };
+
+        let result = run_post_handshake(
+            stream,
+            session_id,
+            sessions.clone(),
+            push_table.clone(),
+            last_matches.clone(),
+            config.clone(),
+        )
+        .await;
+
+        cleanup(
+            session_id,
+            &sessions,
+            &push_table,
+            &last_matches,
+            &config,
+        )
+        .await;
+
+        result
+    }
+
+}
+
+// ============================================================================
+// Free functions (post-refactor): la lógica del post-handshake corre sobre
+// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
+// ============================================================================
+
+async fn run_post_handshake<S>(
+    stream: S,
+    session_id: SessionId,
+    sessions: SessionRegistry,
+    push_table: SessionTxTable,
+    last_matches: LastMatches,
+    config: ServerConfig,
+) -> std::io::Result<()>
+where
+    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+{
+    // Canal por donde el server inyecta frames push (MatchEvent, etc.).
+    let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
+    push_table.lock().await.insert(session_id, tx);
+
+    // Tras registrar el canal, recomputar matches y emitir diffs.
+    broadcast_match_diffs(&push_table, &last_matches, &config).await;
+
+    // Split: reader en el hilo principal, writer compartido bajo Mutex
+    // entre la writer task (push channel) y el handler de inbound
+    // (que escribe Pong/Error). Mutex serializa writes; es OK porque
+    // la frecuencia de writes por sesión es baja.
+    let (mut reader, writer) = split(stream);
+    let writer = Arc::new(Mutex::new(writer));
+
+    // Writer task: drena el push channel.
+    let writer_for_push = writer.clone();
+    let writer_task = tokio::spawn(async move {
+        while let Some(frame) = rx.recv().await {
+            let mut w = writer_for_push.lock().await;
+            if write_frame(&mut *w, &frame).await.is_err() {
+                break;
+            }
+        }
+    });
+
+    // Reader loop principal.
+    let broker_for_loop = config.broker.clone();
+    let result: std::io::Result<()> = loop {
+        match read_frame(&mut reader).await {
+            Ok(frame) => {
+                match handle_inbound_frame(
+                    session_id,
+                    frame,
+                    &writer,
+                    &sessions,
+                    broker_for_loop.as_ref(),
+                )
+                .await
+                {
+                    Ok(true) => continue,
+                    Ok(false) => break Ok(()),
+                    Err(e) => break Err(e),
+                }
+            }
+            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
+                debug!(session = %session_id, "cliente cerró sin Farewell");
+                break Ok(());
+            }
+            Err(e) => break Err(e),
+        }
+    };
+
+    // Cerrar writer: drop nuestro Arc y abortar la task. La task
+    // saldrá igual cuando rx se cierre por drop del último Sender,
+    // pero abortarla es más rápido que esperar a que próximo recv()
+    // observe el cierre.
+    drop(writer);
+    writer_task.abort();
+    let _ = writer_task.await;
+
+    result
+}
+
+async fn handle_inbound_frame<S>(
+    session_id: SessionId,
+    frame: Frame,
+    writer: &Arc<Mutex<WriteHalf<S>>>,
+    sessions: &SessionRegistry,
+    broker_for_match: Option<&SharedBroker>,
+) -> std::io::Result<bool>
+where
+    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+{
+    match frame {
+        Frame::Ping(Ping { session }) if session == session_id => {
+            let pong = Pong {
+                timestamp_ms: now_ms(),
+            };
+            let mut w = writer.lock().await;
+            write_frame(&mut *w, &Frame::Pong(pong)).await?;
+            Ok(true)
+        }
+        Frame::Ping(_) => {
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::Error(HandshakeError::Unauthorized(
+                    "session-id no coincide".into(),
+                )),
+            )
+            .await?;
+            Ok(true)
+        }
+        Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
+        Frame::Farewell(_) => {
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::Error(HandshakeError::Unauthorized(
+                    "session-id no coincide".into(),
+                )),
+            )
+            .await?;
+            Ok(true)
+        }
+        Frame::ListSessions(crate::messages::ListSessions { session })
+            if session == session_id =>
+        {
+            let list = build_session_list(sessions).await;
+            let mut w = writer.lock().await;
+            write_frame(&mut *w, &Frame::SessionList(list)).await?;
+            Ok(true)
+        }
+        Frame::ListSessions(_) => {
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::Error(HandshakeError::Unauthorized(
+                    "session-id no coincide".into(),
+                )),
+            )
+            .await?;
+            Ok(true)
+        }
+        Frame::ListMatches(crate::messages::ListMatches { session })
+            if session == session_id =>
+        {
+            let matches = match &broker_for_match {
+                Some(b) => b.lock().await.all_matches(),
+                None => Vec::new(),
+            };
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::MatchList(crate::messages::MatchList { matches }),
+            )
+            .await?;
+            Ok(true)
+        }
+        Frame::ListMatches(_) => {
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::Error(HandshakeError::Unauthorized(
+                    "session-id no coincide".into(),
+                )),
+            )
+            .await?;
+            Ok(true)
+        }
+        _ => {
+            let mut w = writer.lock().await;
+            write_frame(
+                &mut *w,
+                &Frame::Error(HandshakeError::Rejected(
+                    "frame inesperado tras handshake".into(),
+                )),
+            )
+            .await?;
+            Ok(true)
+        }
+    }
+}
+
+/// Snapshot read-only de la `SessionRegistry` proyectado a la forma
+/// de wire para el frame `SessionList`. Suelta el lock antes de
+/// retornar para que el writer del frame no contenga el mutex.
+async fn build_session_list(sessions: &SessionRegistry) -> crate::messages::SessionList {
+    let table = sessions.lock().await;
+    let entries = table
+        .iter()
+        .map(|(id, resolved)| crate::messages::SessionEntry {
+            session: *id,
+            label: resolved.card.label.clone(),
+            schema_version: resolved.card.schema_version,
+            outputs: resolved
+                .card
+                .flow
+                .output
+                .iter()
+                .map(|f| f.name.clone())
+                .collect(),
+            inputs: resolved
+                .card
+                .flow
+                .input
+                .iter()
+                .map(|f| f.name.clone())
+                .collect(),
+            conscious: resolved.wit.is_some(),
+        })
+        .collect();
+    crate::messages::SessionList { entries }
+}
+
+/// Limpieza atómica de las vistas registradas + (si net activo) retiro
+/// de anuncios DHT de los outputs de la Card. Se ejecuta tanto si la
+/// sesión cierra por Farewell, EOF, o error. Tras desregistrar, emite
+/// diffs a las sesiones que perdieron el match contra ésta.
+async fn cleanup(
+    session_id: SessionId,
+    sessions: &SessionRegistry,
+    push_table: &SessionTxTable,
+    last_matches: &LastMatches,
+    config: &ServerConfig,
+) {
+    // Tomamos la Card ANTES de borrarla — si net está configurado
+    // necesitamos sus outputs para llamar withdraw_outputs. `remove`
+    // devuelve el valor extraído.
+    let removed_card = sessions.lock().await.remove(&session_id);
+    push_table.lock().await.remove(&session_id);
+    last_matches.lock().await.remove(&session_id);
+    if let Some(broker) = &config.broker {
+        broker.lock().await.unregister(session_id);
+    }
+    if let (Some(net), Some(resolved)) = (&config.net, removed_card) {
+        crate::network::withdraw_outputs(net, &resolved.card);
+    }
+    broadcast_match_diffs(push_table, last_matches, config).await;
+}
+
+/// Recomputa los matches para todas las sesiones registradas y empuja
+/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
+/// respecto al último estado conocido.
+async fn broadcast_match_diffs(
+    push_table: &SessionTxTable,
+    last_matches: &LastMatches,
+    config: &ServerConfig,
+) {
+    let broker = match &config.broker {
+        Some(b) => b,
+        None => return,
+    };
+
+    let b = broker.lock().await;
+    let push_table = push_table.lock().await;
+    let mut last = last_matches.lock().await;
+
+    debug!(
+        target: "card_handshake::broadcast",
+        cards = b.len(),
+        push_subscribers = push_table.len(),
+        "broadcast_match_diffs"
+    );
+
+    let cards: Vec<_> = b.cards().cloned().collect();
+
+    for cons in &cards {
+        let cons_session = cons.session;
+        let tx = match push_table.get(&cons_session) {
+            Some(tx) => tx,
+            None => continue,
+        };
+        let cons_last = last.entry(cons_session).or_default();
+
+        for input in &cons.inputs {
+            let new_match = b.find_producer_for(cons_session, &input.name);
+            let new_endpoint = new_match.as_ref().map(|m| m.producer.clone());
+            let old_endpoint = cons_last.get(&input.name).cloned();
+
+            if old_endpoint == new_endpoint {
+                continue;
+            }
+
+            if let Some(m) = &new_match {
+                let producer_service_socket = b
+                    .cards()
+                    .find(|c| c.session == m.producer.session)
+                    .and_then(|c| c.service_socket.clone());
+                let event = MatchEvent {
+                    kind: MatchEventKind::Available,
+                    consumer_flow: input.name.clone(),
+                    producer_session: m.producer.session,
+                    producer_label: m.producer_label.clone(),
+                    producer_flow: m.producer.flow_name.clone(),
+                    ty: m.ty.clone(),
+                    via: m.via,
+                    pinned: m.pinned,
+                    producer_service_socket,
+                };
+                let send_res = tx.try_send(Frame::MatchEvent(event));
+                debug!(
+                    target: "card_handshake::broadcast",
+                    consumer = %cons_session,
+                    flow = %input.name,
+                    producer = %m.producer_label,
+                    result = ?send_res.as_ref().map(|_| "ok").unwrap_or_else(|e| match e {
+                        tokio::sync::mpsc::error::TrySendError::Full(_) => "full",
+                        tokio::sync::mpsc::error::TrySendError::Closed(_) => "closed",
+                    }),
+                    "Available pushed"
+                );
+            } else {
+                let event = MatchEvent {
+                    kind: MatchEventKind::Lost,
+                    consumer_flow: input.name.clone(),
+                    producer_session: Ulid::nil(),
+                    producer_label: String::new(),
+                    producer_flow: String::new(),
+                    ty: input.ty.clone(),
+                    via: chasqui_broker::MatchStrategy::Exact,
+                    pinned: false,
+                    producer_service_socket: None,
+                };
+                let _ = tx.try_send(Frame::MatchEvent(event));
+            }
+
+            if let Some(ep) = new_endpoint {
+                cons_last.insert(input.name.clone(), ep);
+            } else {
+                cons_last.remove(&input.name);
+            }
+        }
+    }
+}
+
+/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la
+/// sesión y emite HelloAck.
+async fn do_handshake<S>(
+    stream: &mut S,
+    config: &ServerConfig,
+    sessions: &SessionRegistry,
+    expected_peer: Option<PeerId>,
+) -> std::io::Result<Option<SessionId>>
+where
+    S: AsyncRead + AsyncWrite + Unpin,
+{
+    let frame = read_frame(stream).await?;
+    let hello = match frame {
+        Frame::Hello(h) => h,
+        _ => {
+            write_frame(
+                stream,
+                &Frame::Error(HandshakeError::Rejected(
+                    "primer frame debe ser Hello".into(),
+                )),
+            )
+            .await?;
+            return Ok(None);
+        }
+    };
+
+    if let Some(err) = validate_hello(&hello) {
+        write_frame(stream, &Frame::Error(err)).await?;
+        return Ok(None);
+    }
+
+    // Identity cert (multi-key identity, opcional): si el cliente
+    // adjuntó cert, la "identidad lógica" del peer es el master
+    // derivado del cert (estable across rotaciones), no el session
+    // peer_id (efímero). Sin cert, fallback al modelo de Fase 3
+    // (logical = session). Esto permite migración gradual y
+    // backwards compatibility con clientes que no usan identity.
+    let logical_peer = if let (Some(session_peer), Some(cert)) =
+        (expected_peer, &hello.identity_cert)
+    {
+        let session_pk_bytes: &[u8] = match &hello.signature {
+            Some(sig) => &sig.public_key,
+            None => {
+                write_frame(
+                    stream,
+                    &Frame::Error(HandshakeError::Unauthorized(
+                        "Hello con identity_cert requiere también signature".into(),
+                    )),
+                )
+                .await?;
+                return Ok(None);
+            }
+        };
+        match cert.verify_against_session(session_pk_bytes) {
+            Ok(master_peer) => {
+                debug!(
+                    session = %session_peer,
+                    master = %master_peer,
+                    "identity cert válido — policy se evalúa contra master_peer"
+                );
+                Some(master_peer)
+            }
+            Err(e) => {
+                write_frame(
+                    stream,
+                    &Frame::Error(HandshakeError::Unauthorized(format!(
+                        "identity cert inválido: {e}"
+                    ))),
+                )
+                .await?;
+                debug!(peer = %session_peer, error = %e, "cert rechazado");
+                return Ok(None);
+            }
+        }
+    } else {
+        expected_peer
+    };
+
+    // Policy gate (path libp2p): si está configurada, el peer
+    // autenticado debe pasar la política (deny first, luego allow).
+    // El peer evaluado es `logical_peer`: master si hay cert,
+    // session si no. Se chequea ANTES de la firma porque es
+    // comparación O(log n) sin crypto. La política no se aplica
+    // al path Unix (autenticación por SO_PEERCRED, no por PeerId).
+    if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
+        let decision = policy.evaluate(&peer);
+        if !decision.is_admitted() {
+            write_frame(
+                stream,
+                &Frame::Error(HandshakeError::Unauthorized(format!(
+                    "peer {peer}: {}",
+                    decision.reason()
+                ))),
+            )
+            .await?;
+            debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
+            return Ok(None);
+        }
+    }
+
+    // Trust gate: en path libp2p (expected_peer = Some), exigir
+    // firma cuya public key derive al peer autenticado por Noise.
+    // En path Unix (expected_peer = None), si la firma viene se
+    // verifica anyway por defensa en profundidad — no es un error
+    // que esté ahí, pero si está debe ser válida.
+    if let Some(peer) = expected_peer {
+        let sig = match &hello.signature {
+            Some(s) => s,
+            None => {
+                write_frame(
+                    stream,
+                    &Frame::Error(HandshakeError::Unauthorized(
+                        "Hello sin firma en conexión remota libp2p".into(),
+                    )),
+                )
+                .await?;
+                return Ok(None);
+            }
+        };
+        if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) {
+            write_frame(
+                stream,
+                &Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))),
+            )
+            .await?;
+            debug!(peer = %peer, error = %e, "firma rechazada");
+            return Ok(None);
+        }
+    } else if let Some(sig) = &hello.signature {
+        // Firma presente en path local: no exigida pero verificada.
+        // Si está y no valida, es un signo de Hello mal-construido y
+        // rechazamos por seguridad.
+        // Para Unix no tenemos peer_id contra el cual comparar; se
+        // verifica sólo la consistencia interna (firma sobre payload
+        // con la public_key declarada).
+        match card_net::PublicKey::try_decode_protobuf(&sig.public_key) {
+            Ok(pk) => {
+                let payload = match postcard::to_allocvec(&(
+                    crate::signature::SIGNATURE_VERSION,
+                    &hello.card,
+                    &hello.wit,
+                )) {
+                    Ok(b) => b,
+                    Err(_) => {
+                        write_frame(
+                            stream,
+                            &Frame::Error(HandshakeError::Internal(
+                                "no pude codificar payload para verificar firma".into(),
+                            )),
+                        )
+                        .await?;
+                        return Ok(None);
+                    }
+                };
+                if !pk.verify(&payload, &sig.signature) {
+                    write_frame(
+                        stream,
+                        &Frame::Error(HandshakeError::Unauthorized(
+                            "firma del Hello presente pero inválida".into(),
+                        )),
+                    )
+                    .await?;
+                    return Ok(None);
+                }
+            }
+            Err(e) => {
+                write_frame(
+                    stream,
+                    &Frame::Error(HandshakeError::Unauthorized(format!(
+                        "public_key inválida en firma: {e}"
+                    ))),
+                )
+                .await?;
+                return Ok(None);
+            }
+        }
+    }
+
+    let session_id = Ulid::new();
+    let card: Card = hello.card.into();
+    register_session(session_id, card, hello.wit, config, sessions).await;
+
+    let ack = HelloAck {
+        server_version: crate::HANDSHAKE_VERSION.to_string(),
+        protocol_version: card_core::PROTOCOL_VERSION.to_string(),
+        session: session_id,
+        init_attached: config.init_attached,
+    };
+    write_frame(stream, &Frame::HelloAck(ack)).await?;
+    debug!(session = %session_id, "handshake completado");
+    Ok(Some(session_id))
+}
+
+async fn register_session(
+    session_id: SessionId,
+    card: Card,
+    wit: Option<WitInterface>,
+    config: &ServerConfig,
+    sessions: &SessionRegistry,
+) {
+    if let Some(broker) = &config.broker {
+        broker
+            .lock()
+            .await
+            .register(session_id, &card, wit.clone());
+    }
+    // Si el server tiene net configurado, anunciar los outputs al
+    // DHT para que peers remotos puedan descubrirlos. Idempotente
+    // y best-effort — fallos de Kad no propagan al handshake.
+    if let Some(net) = &config.net {
+        crate::network::announce_outputs(net, &card);
+    }
+    let resolved = match wit {
+        Some(w) => ResolvedCard::from_conscious(card, w),
+        None => ResolvedCard::from_agnostic(card),
+    };
+    sessions.lock().await.insert(session_id, resolved);
+}
+
+fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
+    if hello.schema_version != CARD_SCHEMA_VERSION {
+        return Some(HandshakeError::SchemaMismatch {
+            client: hello.schema_version,
+            server: CARD_SCHEMA_VERSION,
+        });
+    }
+    if hello.protocol_version != card_core::PROTOCOL_VERSION {
+        return Some(HandshakeError::ProtocolMismatch(format!(
+            "cliente={}, servidor={}",
+            hello.protocol_version,
+            card_core::PROTOCOL_VERSION
+        )));
+    }
+    let as_card: Card = Card::from(hello.card.clone());
+    if let Err(e) = as_card.validate() {
+        return Some(HandshakeError::InvalidCard(e.to_string()));
+    }
+    None
+}
+
+fn now_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
diff --git a/02_ruway/chasqui/card-handshake/src/signature.rs b/02_ruway/chasqui/card-handshake/src/signature.rs
new file mode 100644
index 0000000..c9b2fc5
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/signature.rs
@@ -0,0 +1,155 @@
+//! Firma y verificación del payload del `Hello` para trust remoto.
+//!
+//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer
+//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica
+//! del Ente a la identidad de transporte: si Noise autenticó al
+//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión.
+//!
+//! ## Payload firmado
+//!
+//! Bytes postcard de la tupla `(WireCard, Option<WitInterface>)`. Se
+//! eligió postcard porque ya es el wire format del resto del protocolo:
+//! mismo determinismo, sin convertir a otro format sólo para firmar.
+//!
+//! Cualquier campo que entre al payload firmado en el futuro debe
+//! añadirse al final de la tupla (postcard es position-dependent), o
+//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas.
+
+use card_core::{WireCard, WitInterface};
+use card_net::{Keypair, PeerId, PublicKey};
+
+use crate::messages::HelloSignature;
+
+/// Versión del esquema de payload firmado. Si cambia el shape de
+/// `(WireCard, Option<WitInterface>)` o cómo se serializa, bump este
+/// número y el verificador rechaza firmas antiguas.
+pub const SIGNATURE_VERSION: u8 = 1;
+
+/// Errores de verificación de firma.
+#[derive(Debug, thiserror::Error)]
+pub enum SignatureError {
+    #[error("public_key inválida (libp2p decode protobuf): {0}")]
+    DecodeKey(String),
+    #[error("encode del payload falló: {0}")]
+    EncodePayload(String),
+    #[error("firma rechazada: bytes inválidos para la public_key")]
+    Invalid,
+    #[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")]
+    PeerMismatch { signer: PeerId, expected: PeerId },
+    #[error("firma del Hello faltante (requerida para conexión remota libp2p)")]
+    Missing,
+    #[error("firma del Hello inesperada en path local sin trust remoto")]
+    Unexpected,
+}
+
+/// Construye los bytes canónicos a firmar/verificar para un Hello.
+/// Postcard determinístico de `(version, WireCard, Option<WitInterface>)`.
+fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, SignatureError> {
+    let tup = (SIGNATURE_VERSION, card, wit);
+    postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string()))
+}
+
+/// Firma `(card, wit)` con la `keypair`. La public key derivada de
+/// `keypair` debe coincidir con la identidad libp2p del peer cuando
+/// el verificador la chequee.
+pub fn sign_hello(
+    keypair: &Keypair,
+    card: &WireCard,
+    wit: &Option<WitInterface>,
+) -> Result<HelloSignature, SignatureError> {
+    let bytes = payload_bytes(card, wit)?;
+    let signature_bytes = keypair
+        .sign(&bytes)
+        .map_err(|e| SignatureError::EncodePayload(e.to_string()))?;
+    Ok(HelloSignature {
+        public_key: keypair.public().encode_protobuf(),
+        signature: signature_bytes,
+    })
+}
+
+/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que
+/// la public key declarada coincide con `expected_peer` (la identidad
+/// libp2p autenticada por Noise).
+///
+/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto.
+pub fn verify_hello(
+    sig: &HelloSignature,
+    card: &WireCard,
+    wit: &Option<WitInterface>,
+    expected_peer: PeerId,
+) -> Result<(), SignatureError> {
+    let public_key = PublicKey::try_decode_protobuf(&sig.public_key)
+        .map_err(|e| SignatureError::DecodeKey(e.to_string()))?;
+    let signer_peer = public_key.to_peer_id();
+    if signer_peer != expected_peer {
+        return Err(SignatureError::PeerMismatch {
+            signer: signer_peer,
+            expected: expected_peer,
+        });
+    }
+    let bytes = payload_bytes(card, wit)?;
+    if !public_key.verify(&bytes, &sig.signature) {
+        return Err(SignatureError::Invalid);
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use card_core::Card;
+
+    fn sample_card() -> WireCard {
+        Card::new("test.signed").into()
+    }
+
+    #[test]
+    fn sign_then_verify_roundtrip() {
+        let kp = Keypair::generate_ed25519();
+        let peer = kp.public().to_peer_id();
+        let card = sample_card();
+        let wit = None;
+        let sig = sign_hello(&kp, &card, &wit).unwrap();
+        verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar");
+    }
+
+    #[test]
+    fn verify_rejects_wrong_peer() {
+        let kp = Keypair::generate_ed25519();
+        let other = Keypair::generate_ed25519().public().to_peer_id();
+        let card = sample_card();
+        let wit = None;
+        let sig = sign_hello(&kp, &card, &wit).unwrap();
+        let err = verify_hello(&sig, &card, &wit, other).unwrap_err();
+        assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}");
+    }
+
+    #[test]
+    fn verify_rejects_tampered_card() {
+        let kp = Keypair::generate_ed25519();
+        let peer = kp.public().to_peer_id();
+        let original = sample_card();
+        let wit = None;
+        let sig = sign_hello(&kp, &original, &wit).unwrap();
+
+        // Verificamos contra una Card distinta (mismo shape, distinto label).
+        let tampered: WireCard = Card::new("test.tampered").into();
+        let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err();
+        assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
+    }
+
+    #[test]
+    fn verify_rejects_corrupted_signature() {
+        let kp = Keypair::generate_ed25519();
+        let peer = kp.public().to_peer_id();
+        let card = sample_card();
+        let wit = None;
+        let mut sig = sign_hello(&kp, &card, &wit).unwrap();
+        // Flip un bit de la firma.
+        if let Some(b) = sig.signature.last_mut() {
+            *b ^= 0x01;
+        }
+        let err = verify_hello(&sig, &card, &wit, peer).unwrap_err();
+        assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/src/transport.rs b/02_ruway/chasqui/card-handshake/src/transport.rs
new file mode 100644
index 0000000..03e7b51
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/src/transport.rs
@@ -0,0 +1,52 @@
+//! Convenciones de transporte: dónde vive el socket del Init.
+//!
+//! Resolución del path canónico:
+//! 1. Variable de entorno [`SOCKET_ENV`] si está definida (override
+//!    explícito, prioridad máxima).
+//! 2. `$XDG_RUNTIME_DIR/brahman-init.sock` (sesión usuario).
+//! 3. `$TMPDIR/brahman-init.sock` (fallback portable).
+
+use std::path::PathBuf;
+
+/// Variable de entorno que sobreescribe la ruta del socket del Init.
+pub const SOCKET_ENV: &str = "BRAHMAN_INIT_SOCKET";
+
+/// Nombre del socket dentro del runtime dir.
+pub const SOCKET_NAME: &str = "brahman-init.sock";
+
+/// Ruta canónica al socket del Init brahman.
+pub fn default_socket_path() -> PathBuf {
+    if let Ok(p) = std::env::var(SOCKET_ENV) {
+        return PathBuf::from(p);
+    }
+    let base = std::env::var_os("XDG_RUNTIME_DIR")
+        .map(PathBuf::from)
+        .unwrap_or_else(std::env::temp_dir);
+    base.join(SOCKET_NAME)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn env_override_wins() {
+        // Nota: estos tests modifican entorno del proceso. `cargo test`
+        // los corre paralelos por defecto pero usamos un nombre de var
+        // único y restablecemos al final.
+        let key = "BRAHMAN_INIT_SOCKET_TEST_OVERRIDE";
+        // SAFETY: sólo escribimos una variable local al test; sin
+        // contaminar SOCKET_ENV.
+        std::env::set_var(key, "/tmp/explicit.sock");
+        let saved = std::env::var(SOCKET_ENV).ok();
+        std::env::set_var(SOCKET_ENV, "/tmp/explicit.sock");
+        let p = default_socket_path();
+        assert_eq!(p, PathBuf::from("/tmp/explicit.sock"));
+        // Restaurar
+        match saved {
+            Some(v) => std::env::set_var(SOCKET_ENV, v),
+            None => std::env::remove_var(SOCKET_ENV),
+        }
+        std::env::remove_var(key);
+    }
+}
diff --git a/02_ruway/chasqui/card-handshake/tests/handshake.rs b/02_ruway/chasqui/card-handshake/tests/handshake.rs
new file mode 100644
index 0000000..3c716b6
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/tests/handshake.rs
@@ -0,0 +1,480 @@
+//! Tests de integración: levanta server + client en el mismo proceso,
+//! ejercita el round-trip completo del protocolo.
+
+use std::collections::BTreeSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+use chasqui_broker::{Broker, BrokerConfig};
+use card_core::{
+    Card, CgroupSpec, Flow, Flows, NamespaceSet, Payload, ResourceLimits, SomaSpec, Supervision,
+    TypeRef, CARD_SCHEMA_VERSION,
+};
+use card_handshake::{
+    client::{Client, ClientError},
+    codec::{read_frame, write_frame},
+    messages::{Frame, HandshakeError, Hello, Ping},
+    server::{Server, ServerConfig},
+};
+use tokio::net::UnixStream;
+use tokio::sync::Mutex;
+use ulid::Ulid;
+
+fn sample_card(label: &str) -> Card {
+    Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        lineage: None,
+        label: label.into(),
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        soma: SomaSpec {
+            cgroup: CgroupSpec {
+                path: "ente.slice/test".into(),
+                cpu_weight: None,
+                io_weight: None,
+            },
+            namespaces: NamespaceSet::default(),
+            rlimits: ResourceLimits::default(),
+            cpu_affinity: None,
+        },
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        ..Default::default()
+    }
+}
+
+/// Genera una ruta de socket única bajo TMPDIR. No la creamos —
+/// el server la creará al hacer bind.
+fn sock_path(name: &str) -> std::path::PathBuf {
+    std::env::temp_dir().join(format!(
+        "brahman-test-{}-{}-{}.sock",
+        name,
+        std::process::id(),
+        Ulid::new()
+    ))
+}
+
+#[tokio::test]
+async fn full_handshake_roundtrip() {
+    let path = sock_path("happy");
+    let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, policy: None }).unwrap();
+
+    let session_handle = tokio::spawn({
+        async move {
+            let session = server.accept_one().await.unwrap();
+            session.handle().await.unwrap();
+        }
+    });
+
+    let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
+    assert!(client.server_info().init_attached);
+    assert_eq!(
+        client.server_info().protocol_version,
+        card_core::PROTOCOL_VERSION
+    );
+
+    let mut last = 0u64;
+    for _ in 0..3 {
+        let ts = client.ping().await.unwrap();
+        assert!(ts >= last);
+        last = ts;
+        tokio::time::sleep(Duration::from_millis(2)).await;
+    }
+    client.farewell().await.unwrap();
+
+    tokio::time::timeout(Duration::from_secs(2), session_handle)
+        .await
+        .expect("server hung after farewell")
+        .unwrap();
+}
+
+#[tokio::test]
+async fn list_sessions_returns_currently_registered() {
+    // Levantamos un server con broker (requerido para que el registro
+    // pase por el path real) y conectamos 3 clientes. El último pide
+    // ListSessions y debe ver a los 2 anteriores + a sí mismo.
+    let path = sock_path("listsess");
+    let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let server = Server::bind(
+        &path,
+        ServerConfig {
+            init_attached: true,
+            broker: Some(broker),
+            net: None,
+            policy: None,
+        },
+    )
+    .unwrap();
+
+    // Una task accept loop genérica para los 3 clientes.
+    let server_handle = tokio::spawn(async move {
+        for _ in 0..3 {
+            let session = server.accept_one().await.unwrap();
+            tokio::spawn(async move {
+                let _ = session.handle().await;
+            });
+        }
+        // Mantener el server vivo para que las sesiones puedan
+        // mantenerse abiertas mientras el observer pregunta.
+        std::future::pending::<()>().await;
+    });
+
+    let mut alpha = Client::connect(&path, sample_card("producer-alpha"))
+        .await
+        .unwrap();
+    let mut beta = Client::connect(&path, sample_card("producer-beta"))
+        .await
+        .unwrap();
+    // observer es el que va a preguntar.
+    let mut observer = Client::connect(&path, sample_card("observer"))
+        .await
+        .unwrap();
+
+    let list = observer.list_sessions().await.unwrap();
+    assert_eq!(list.entries.len(), 3, "deberían verse 3 sesiones activas");
+
+    let labels: BTreeSet<&str> = list.entries.iter().map(|e| e.label.as_str()).collect();
+    assert!(labels.contains("producer-alpha"));
+    assert!(labels.contains("producer-beta"));
+    assert!(labels.contains("observer"));
+
+    // schema_version + conscious sanity en la propia entry del observer.
+    let me = list
+        .entries
+        .iter()
+        .find(|e| e.label == "observer")
+        .unwrap();
+    assert_eq!(me.schema_version, card_core::CARD_SCHEMA_VERSION);
+    assert!(!me.conscious, "observer no envió WIT — debería ser agnostic");
+
+    alpha.farewell().await.unwrap();
+    beta.farewell().await.unwrap();
+    observer.farewell().await.unwrap();
+    server_handle.abort();
+}
+
+#[tokio::test]
+async fn rejects_invalid_card_client_side() {
+    let path = sock_path("invalid");
+    let server = Server::bind(&path, ServerConfig::default()).unwrap();
+    let session_handle = tokio::spawn(async move {
+        // No esperamos que el server complete: el cliente corta antes.
+        let _ = tokio::time::timeout(Duration::from_secs(1), async move {
+            let session = server.accept_one().await.unwrap();
+            session.handle().await.unwrap();
+        })
+        .await;
+    });
+
+    let mut bad = sample_card("placeholder");
+    bad.label = String::new();
+    let err = Client::connect(&path, bad).await.unwrap_err();
+    assert!(matches!(err, ClientError::InvalidCard(_)));
+
+    session_handle.abort();
+}
+
+#[tokio::test]
+async fn server_rejects_protocol_mismatch() {
+    let path = sock_path("mismatch");
+    let server = Server::bind(&path, ServerConfig::default()).unwrap();
+    let session_handle = tokio::spawn(async move {
+        let session = server.accept_one().await.unwrap();
+        session.handle().await.unwrap();
+    });
+
+    let mut stream = UnixStream::connect(&path).await.unwrap();
+    let hello = Hello {
+        schema_version: CARD_SCHEMA_VERSION,
+        protocol_version: "999.0.0".into(),
+        card: sample_card("future-module").into(),
+        wit: None,
+        signature: None,
+        identity_cert: None,
+    };
+    write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
+
+    match read_frame(&mut stream).await.unwrap() {
+        Frame::Error(HandshakeError::ProtocolMismatch(_)) => {}
+        other => panic!("esperado ProtocolMismatch, got {other:?}"),
+    }
+
+    tokio::time::timeout(Duration::from_secs(2), session_handle)
+        .await
+        .expect("server hung after rejecting")
+        .unwrap();
+}
+
+// =====================================================================
+// Integración handshake ↔ broker
+// =====================================================================
+
+fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
+    Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.into(),
+        soma: SomaSpec {
+            cgroup: CgroupSpec {
+                path: "ente.slice/test".into(),
+                cpu_weight: None,
+                io_weight: None,
+            },
+            namespaces: NamespaceSet::default(),
+            rlimits: ResourceLimits::default(),
+            cpu_affinity: None,
+        },
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        flow: Flows { input, output },
+        ..Default::default()
+    }
+}
+
+fn flow(name: &str, ty: TypeRef) -> Flow {
+    Flow {
+        name: name.into(),
+        ty,
+        pin_to: None,
+    }
+}
+
+/// Espera hasta que `broker.len() >= n` o timeout.
+async fn wait_for_broker_len(broker: &Arc<Mutex<Broker>>, n: usize) {
+    for _ in 0..50 {
+        if broker.lock().await.len() >= n {
+            return;
+        }
+        tokio::time::sleep(Duration::from_millis(10)).await;
+    }
+    panic!("broker no alcanzó {n} entradas en 500ms");
+}
+
+#[tokio::test]
+async fn broker_registers_and_unregisters_with_session() {
+    let path = sock_path("broker-lifecycle");
+    let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let server = Server::bind(
+        &path,
+        ServerConfig {
+            init_attached: false,
+            broker: Some(broker.clone()),
+            net: None,
+            policy: None,
+        },
+    )
+    .unwrap();
+
+    let session_handle = tokio::spawn(async move {
+        let session = server.accept_one().await.unwrap();
+        session.handle().await.unwrap();
+    });
+
+    let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
+    let session_id = client.session();
+
+    // Tras el handshake, la Card debe estar registrada en el broker.
+    wait_for_broker_len(&broker, 1).await;
+    {
+        let b = broker.lock().await;
+        assert_eq!(b.len(), 1);
+        assert!(b.sessions().any(|s| s == session_id));
+    }
+
+    client.farewell().await.unwrap();
+    tokio::time::timeout(Duration::from_secs(2), session_handle)
+        .await
+        .expect("server colgó tras farewell")
+        .unwrap();
+
+    // Tras el cleanup, el broker queda vacío.
+    {
+        let b = broker.lock().await;
+        assert_eq!(b.len(), 0);
+    }
+}
+
+#[tokio::test]
+async fn broker_matches_two_live_modules() {
+    let path = sock_path("broker-match");
+    let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let server = Server::bind(
+        &path,
+        ServerConfig {
+            init_attached: false,
+            broker: Some(broker.clone()),
+            net: None,
+            policy: None,
+        },
+    )
+    .unwrap();
+
+    // Server loop: usa la API run() para manejar accept+spawn.
+    let server_handle = tokio::spawn(async move {
+        let _ = server.run().await;
+    });
+
+    // Productor: emite "out" tipo string.
+    let producer_card = card_with_flows(
+        "dht",
+        vec![],
+        vec![flow(
+            "out",
+            TypeRef::Primitive {
+                name: "string".into(),
+            },
+        )],
+    );
+    let mut producer = Client::connect(&path, producer_card).await.unwrap();
+    wait_for_broker_len(&broker, 1).await;
+
+    // Consumidor: pide "in" tipo string.
+    let consumer_card = card_with_flows(
+        "ui",
+        vec![flow(
+            "in",
+            TypeRef::Primitive {
+                name: "string".into(),
+            },
+        )],
+        vec![],
+    );
+    let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
+    wait_for_broker_len(&broker, 2).await;
+
+    // El broker debe encontrar el match consumer.in ← producer.out.
+    let m = {
+        let b = broker.lock().await;
+        b.find_producer_for(consumer.session(), "in")
+    }
+    .expect("broker no encontró match");
+    assert_eq!(m.consumer_label, "ui");
+    assert_eq!(m.producer_label, "dht");
+    assert_eq!(m.producer.flow_name, "out");
+
+    // Cuando el productor se va, el match desaparece.
+    producer.farewell().await.unwrap();
+    for _ in 0..50 {
+        if broker.lock().await.len() < 2 {
+            break;
+        }
+        tokio::time::sleep(Duration::from_millis(10)).await;
+    }
+    {
+        let b = broker.lock().await;
+        assert!(b.find_producer_for(consumer.session(), "in").is_none());
+    }
+
+    consumer.farewell().await.unwrap();
+    server_handle.abort();
+}
+
+#[tokio::test]
+async fn match_event_pushed_on_producer_arrival() {
+    use card_handshake::messages::MatchEventKind;
+
+    let path = sock_path("push-match");
+    let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let server = Server::bind(
+        &path,
+        ServerConfig {
+            init_attached: false,
+            broker: Some(broker.clone()),
+            net: None,
+            policy: None,
+        },
+    )
+    .unwrap();
+
+    let server_handle = tokio::spawn(async move {
+        let _ = server.run().await;
+    });
+
+    // El consumidor llega primero — sin productor, no hay match aún.
+    let consumer_card = card_with_flows(
+        "ui",
+        vec![flow(
+            "in",
+            TypeRef::Primitive {
+                name: "json".into(),
+            },
+        )],
+        vec![],
+    );
+    let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
+
+    // No debería haber evento todavía.
+    let no_event = consumer
+        .await_event(Duration::from_millis(100))
+        .await
+        .unwrap();
+    assert!(no_event.is_none(), "evento inesperado: {no_event:?}");
+
+    // Llega el productor → consumer debe recibir Available.
+    let producer_card = card_with_flows(
+        "dht",
+        vec![],
+        vec![flow(
+            "out",
+            TypeRef::Primitive {
+                name: "json".into(),
+            },
+        )],
+    );
+    let mut producer = Client::connect(&path, producer_card).await.unwrap();
+
+    let ev = consumer
+        .await_event(Duration::from_secs(2))
+        .await
+        .unwrap()
+        .expect("Available no llegó");
+    assert_eq!(ev.kind, MatchEventKind::Available);
+    assert_eq!(ev.consumer_flow, "in");
+    assert_eq!(ev.producer_label, "dht");
+    assert_eq!(ev.producer_flow, "out");
+
+    // El productor se va → consumer debe recibir Lost.
+    producer.farewell().await.unwrap();
+    let ev = consumer
+        .await_event(Duration::from_secs(2))
+        .await
+        .unwrap()
+        .expect("Lost no llegó");
+    assert_eq!(ev.kind, MatchEventKind::Lost);
+    assert_eq!(ev.consumer_flow, "in");
+
+    consumer.farewell().await.unwrap();
+    server_handle.abort();
+}
+
+#[tokio::test]
+async fn ping_before_hello_rejected() {
+    let path = sock_path("ping-no-hello");
+    let server = Server::bind(&path, ServerConfig::default()).unwrap();
+    let session_handle = tokio::spawn(async move {
+        let session = server.accept_one().await.unwrap();
+        session.handle().await.unwrap();
+    });
+
+    // Conectamos y mandamos un Ping sin haber saludado.
+    let mut stream = UnixStream::connect(&path).await.unwrap();
+    write_frame(
+        &mut stream,
+        &Frame::Ping(Ping {
+            session: Ulid::new(),
+        }),
+    )
+    .await
+    .unwrap();
+
+    match read_frame(&mut stream).await.unwrap() {
+        Frame::Error(HandshakeError::Rejected(_)) => {}
+        other => panic!("esperado Rejected, got {other:?}"),
+    }
+
+    tokio::time::timeout(Duration::from_secs(2), session_handle)
+        .await
+        .expect("server hung after rejecting")
+        .unwrap();
+}
diff --git a/02_ruway/chasqui/card-handshake/tests/network_discovery.rs b/02_ruway/chasqui/card-handshake/tests/network_discovery.rs
new file mode 100644
index 0000000..4ddde1e
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/tests/network_discovery.rs
@@ -0,0 +1,340 @@
+//! Test E2E de Fase 2: discovery remoto vía DHT.
+//!
+//! Pipeline:
+//! 1. **Provider node (A)**: arma server con `BrahmanNet` configurado;
+//!    listen TCP; un cliente local registra una Card con un output
+//!    flow. El server llama `announce_outputs` automáticamente, lo
+//!    que hace `start_providing` en el DHT bajo la key derivada del
+//!    flow.
+//! 2. **Consumer node (B)**: arma su propio `BrahmanNet`; dial-ea al
+//!    multiaddr del provider para que ambos se conozcan vía Identify
+//!    (esto popula sus respectivos routing tables de Kademlia).
+//! 3. **B llama `find_remote_providers(flow_name, type)`**: la query
+//!    DHT propaga vía Kad, y eventually el provider responde con su
+//!    `PeerId`.
+//! 4. **Verificación**: el `PeerId` que B descubre coincide con el
+//!    de A.
+//!
+//! Notas:
+//! - Kademlia replication factor por defecto es 20; con 2 nodos no
+//!   hay propagación material — A es el único provider, B llega a A
+//!   vía la conexión directa establecida en step 2 y obtiene el record
+//!   del store local de A.
+//! - El test usa flow `monad-list:json` por familiaridad (es el flow
+//!   real que `chasqui daemon` declara). Sirve también como prueba de
+//!   que el sistema completo (daemon + DHT) funcionaría con cero
+//!   cambios en la Card.
+
+use std::collections::BTreeSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+use chasqui_broker::{Broker, BrokerConfig};
+use card_core::{
+    ulid::Ulid, Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
+    CARD_SCHEMA_VERSION,
+};
+use card_handshake::network::{find_remote_providers, run_libp2p_accept_loop};
+use card_handshake::server::{Server, ServerConfig};
+use card_net::{BrahmanNet, Multiaddr, Protocol};
+use tempfile::TempDir;
+use tokio::sync::Mutex;
+
+fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
+    Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.into(),
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        permissions: Default::default(),
+        soma: Default::default(),
+        payload: Payload::Virtual,
+        supervision: Supervision::Delegate,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        flow: Flows {
+            input: vec![],
+            output: vec![Flow {
+                name: flow_name.into(),
+                ty: TypeRef::Primitive {
+                    name: type_name.into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Default::default()
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn dht_discovery_finds_remote_provider() {
+    // ---- Node A (provider): server + libp2p net + Card con output ----
+    let tmp = TempDir::new().unwrap();
+    let a_unix = tmp.path().join("a.sock");
+
+    let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let a_net = Arc::new(BrahmanNet::new().unwrap());
+    let a_peer = a_net.peer_id;
+
+    let a_server = Arc::new(
+        Server::bind(
+            &a_unix,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(a_broker.clone()),
+                net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+
+    let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
+    let a_addr = a_net.listen(listen_addr).await;
+    let mut a_full_addr = a_addr.clone();
+    a_full_addr.push(Protocol::P2p(a_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
+
+    // Unix accept loop: necesario para que Client::connect al socket
+    // local no cuelgue (Server no se auto-accepta; el caller arma el
+    // loop). Cada session entrante corre en su propia task.
+    {
+        let s = a_server.clone();
+        tokio::spawn(async move {
+            loop {
+                match s.accept_one().await {
+                    Ok(session) => {
+                        tokio::spawn(async move {
+                            let _ = session.handle().await;
+                        });
+                    }
+                    Err(_) => break,
+                }
+            }
+        });
+    }
+
+    // Registrar la Card local en A con un flow output.
+    let card = provider_card("test.engine_remote", "monad-list", "json");
+    let mut local_client = card_handshake::client::Client::connect(&a_unix, card)
+        .await
+        .expect("registro local en A");
+
+    // ---- Node B (consumer): otro net que dial-a a A ----
+    let b_net = BrahmanNet::new().unwrap();
+    b_net.dial(a_full_addr.clone());
+
+    // Esperar a que la conexión se establezca y Identify popule el
+    // routing table de Kad. En localhost con 2 peers, ~250ms es de
+    // sobra; sumamos margen para CI.
+    tokio::time::sleep(Duration::from_millis(500)).await;
+
+    // ---- Discovery: B busca providers de "monad-list:json" ----
+    let providers = find_remote_providers(
+        &b_net,
+        "monad-list",
+        &TypeRef::Primitive {
+            name: "json".into(),
+        },
+    )
+    .await;
+
+    assert!(
+        providers.contains(&a_peer),
+        "B debería descubrir a A vía DHT. Encontrados: {:?}, esperado: {}",
+        providers,
+        a_peer
+    );
+
+    // Sanidad: el cliente local sigue vivo durante todo el test (lo
+    // que mantiene la Card registrada y por tanto el record DHT vivo).
+    local_client.farewell().await.ok();
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn dht_discovery_negative_unknown_flow() {
+    // Mismo setup que el test happy-path, pero B busca un flow que A
+    // NO ofrece. Debe devolver lista vacía dentro del timeout
+    // razonable (no colgarse).
+    let tmp = TempDir::new().unwrap();
+    let a_unix = tmp.path().join("a.sock");
+    let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let a_net = Arc::new(BrahmanNet::new().unwrap());
+    let a_peer = a_net.peer_id;
+
+    let a_server = Arc::new(
+        Server::bind(
+            &a_unix,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(a_broker),
+                net: Some(a_net.clone()),
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+
+    let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
+    let mut a_full = a_addr.clone();
+    a_full.push(Protocol::P2p(a_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
+
+    // Unix accept loop: necesario para que Client::connect al socket
+    // local no cuelgue (Server no se auto-accepta; el caller arma el
+    // loop). Cada session entrante corre en su propia task.
+    {
+        let s = a_server.clone();
+        tokio::spawn(async move {
+            loop {
+                match s.accept_one().await {
+                    Ok(session) => {
+                        tokio::spawn(async move {
+                            let _ = session.handle().await;
+                        });
+                    }
+                    Err(_) => break,
+                }
+            }
+        });
+    }
+
+    let card = provider_card("test.engine_other", "monad-list", "json");
+    let mut local = card_handshake::client::Client::connect(&a_unix, card)
+        .await
+        .unwrap();
+
+    let b_net = BrahmanNet::new().unwrap();
+    b_net.dial(a_full);
+    tokio::time::sleep(Duration::from_millis(500)).await;
+
+    // Buscamos un flow que NADIE anunció.
+    let providers = find_remote_providers(
+        &b_net,
+        "flow-que-no-existe",
+        &TypeRef::Primitive {
+            name: "json".into(),
+        },
+    )
+    .await;
+
+    assert!(
+        providers.is_empty(),
+        "no debería haber providers para un flow inexistente, got: {:?}",
+        providers
+    );
+
+    local.farewell().await.ok();
+}
+
+/// stop_providing test: A registra Card con flow X, B descubre a A.
+/// El cliente local de A hace farewell → cleanup llama
+/// withdraw_outputs → A se quita del provider local store. Una nueva
+/// query desde B (que rutea por A, único peer en el DHT) ya no debe
+/// listarlo.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn dht_discovery_withdraws_on_session_cleanup() {
+    let tmp = TempDir::new().unwrap();
+    let a_unix = tmp.path().join("a.sock");
+    let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let a_net = Arc::new(BrahmanNet::new().unwrap());
+    let a_peer = a_net.peer_id;
+
+    let a_server = Arc::new(
+        Server::bind(
+            &a_unix,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(a_broker),
+                net: Some(a_net.clone()),
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = a_server.sessions();
+
+    let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
+    let mut a_full = a_addr.clone();
+    a_full.push(Protocol::P2p(a_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
+    {
+        let s = a_server.clone();
+        tokio::spawn(async move {
+            loop {
+                match s.accept_one().await {
+                    Ok(session) => {
+                        tokio::spawn(async move {
+                            let _ = session.handle().await;
+                        });
+                    }
+                    Err(_) => break,
+                }
+            }
+        });
+    }
+
+    // Card con un flow output anunciable.
+    let card = provider_card("test.withdraws", "monad-list", "json");
+    let local = card_handshake::client::Client::connect(&a_unix, card)
+        .await
+        .expect("registro local en A");
+
+    let b_net = BrahmanNet::new().unwrap();
+    b_net.dial(a_full);
+    tokio::time::sleep(Duration::from_millis(500)).await;
+
+    // Confirmación previa: A es discoverable.
+    let before = find_remote_providers(
+        &b_net,
+        "monad-list",
+        &TypeRef::Primitive {
+            name: "json".into(),
+        },
+    )
+    .await;
+    assert!(
+        before.contains(&a_peer),
+        "antes del farewell A debería ser discoverable. got: {:?}",
+        before
+    );
+
+    // Farewell del cliente local → server.cleanup → withdraw_outputs.
+    local.farewell().await.ok();
+
+    // Esperamos a que la sesión salga del registro de A (señal de
+    // que cleanup completó).
+    let mut waited = 0;
+    while !sessions.lock().await.is_empty() && waited < 50 {
+        tokio::time::sleep(Duration::from_millis(20)).await;
+        waited += 1;
+    }
+    assert!(
+        sessions.lock().await.is_empty(),
+        "sesión debería estar removida tras farewell"
+    );
+
+    // Pequeño margen extra para que el Command::StopProviding lo
+    // procese el swarm task (no es await-able desde fuera).
+    tokio::time::sleep(Duration::from_millis(100)).await;
+
+    // Nueva query: A ya no debería listarse como provider.
+    let after = find_remote_providers(
+        &b_net,
+        "monad-list",
+        &TypeRef::Primitive {
+            name: "json".into(),
+        },
+    )
+    .await;
+    assert!(
+        !after.contains(&a_peer),
+        "tras farewell + withdraw_outputs, A NO debería ser discoverable. got: {:?}",
+        after
+    );
+}
diff --git a/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs b/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs
new file mode 100644
index 0000000..c7e28ec
--- /dev/null
+++ b/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs
@@ -0,0 +1,525 @@
+//! Test E2E: handshake brahman remoto sobre libp2p stream.
+//!
+//! Pipeline:
+//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
+//!    crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
+//!    montar `run_libp2p_accept_loop`.
+//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
+//!    server; `connect_libp2p` con su Card; `ping`; `farewell`.
+//! 3. Verificar: el server registró la sesión; sessions.len() == 1
+//!    durante la sesión, == 0 después del farewell.
+
+use std::collections::BTreeSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+use chasqui_broker::{Broker, BrokerConfig};
+use card_core::{
+    ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
+    CARD_SCHEMA_VERSION,
+};
+use card_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
+use card_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
+use card_handshake::peer_policy::PeerPolicy;
+use card_handshake::server::{Server, ServerConfig};
+use card_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
+use tempfile::TempDir;
+use tokio::sync::Mutex;
+
+fn sample_card(label: &str) -> Card {
+    Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.into(),
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        permissions: Default::default(),
+        soma: Default::default(),
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::default(),
+        priority: Priority::default(),
+        kind: CardKind::Ente,
+        ..Default::default()
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn libp2p_handshake_roundtrip() {
+    // ---- Server side ----
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+
+    let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(broker.clone()),
+                net: None,
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = server.sessions();
+
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer_id = server_net.peer_id;
+
+    // Listen on a random TCP port.
+    let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
+    let actual_addr = server_net.listen(listen_addr).await;
+    // Inject the libp2p PeerId into the multiaddr so the client knows
+    // who to dial.
+    let mut full_addr = actual_addr.clone();
+    full_addr.push(Protocol::P2p(server_peer_id));
+
+    // Spawn the libp2p accept loop.
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    // ---- Client side ----
+    let client_net = BrahmanNet::new().unwrap();
+    client_net.dial(full_addr.clone());
+
+    // Pequeña espera para que el dial conecte. En un entorno real el
+    // caller usaría un mecanismo de barrier, pero para tests un sleep
+    // corto es suficiente y deterministic en localhost.
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let card = sample_card("test.remote_ente");
+    let client_kp = client_net.keypair();
+    let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp)
+        .await
+        .expect("handshake remoto debería completar");
+
+    // Verificación: el server vio la sesión.
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 1, "una sesión registrada");
+        let resolved = s.values().next().unwrap();
+        assert_eq!(resolved.card.label, "test.remote_ente");
+    }
+
+    // Ping roundtrip.
+    let ts = client.ping().await.expect("ping debería responder");
+    assert!(ts > 0, "timestamp del Pong > 0");
+
+    // Farewell limpio.
+    client.farewell().await.expect("farewell debería completar");
+
+    // Tras el farewell, el cleanup remueve la sesión.
+    // Damos un tick para que el handler procese el frame.
+    tokio::time::sleep(Duration::from_millis(100)).await;
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 0, "sesión removida tras farewell");
+    }
+
+    // peer_id no usado aquí, pero validamos que la API existe.
+    let _ = PeerId::random();
+}
+
+/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair
+/// distinta a la del peer libp2p. El server (que verifica que la
+/// public key del Hello derive al peer_id autenticado por Noise) debe
+/// rechazar con `Unauthorized`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn libp2p_handshake_rejects_mismatched_signing_key() {
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: None,
+                net: None,
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = server.sessions();
+
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer = server_net.peer_id;
+    let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
+    let actual = server_net.listen(listen_addr).await;
+    let mut full = actual.clone();
+    full.push(Protocol::P2p(server_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    let client_net = BrahmanNet::new().unwrap();
+    client_net.dial(full);
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    // Keypair fraudulenta: NO es la del client_net.
+    let evil_keypair = Keypair::generate_ed25519();
+
+    let card = sample_card("test.evil");
+    let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await;
+
+    assert!(
+        result.is_err(),
+        "handshake con keypair fraudulenta debe fallar"
+    );
+
+    // Sanidad: ninguna sesión registrada.
+    let s = sessions.lock().await;
+    assert_eq!(s.len(), 0, "no debería haber sesión registrada");
+}
+
+/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`.
+/// Un cliente con peer_id en la lista pasa el handshake; otro con
+/// peer_id distinto es rechazado con `Unauthorized` ANTES de la
+/// verificación de firma (la allowlist se chequea primero, es más
+/// barata).
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
+    // Pre-generamos las dos identidades cliente para que A pueda
+    // construir la allowlist conociendo cuál es la "permitida".
+    let allowed_kp = Keypair::generate_ed25519();
+    let allowed_peer = allowed_kp.public().to_peer_id();
+    let denied_kp = Keypair::generate_ed25519();
+    // (denied_peer no se necesita para la lista — sólo para clarity)
+    let _ = denied_kp.public().to_peer_id();
+
+    // ---- Server con allowlist activa ----
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: None,
+                net: None,
+                policy: Some(PeerPolicy::from_sets(
+                    Some([allowed_peer].into_iter().collect()),
+                    std::collections::BTreeSet::new(),
+                )),
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = server.sessions();
+
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer = server_net.peer_id;
+    let actual = server_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
+    let mut full = actual.clone();
+    full.push(Protocol::P2p(server_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    // ---- Cliente PERMITIDO ----
+    let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap();
+    allowed_net.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let card_ok = sample_card("test.allowed");
+    let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp)
+        .await
+        .expect("peer en allowlist debe pasar");
+
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 1, "sesión del peer permitido registrada");
+    }
+    allowed_client.farewell().await.ok();
+    tokio::time::sleep(Duration::from_millis(100)).await;
+
+    // ---- Cliente DENEGADO ----
+    let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap();
+    denied_net.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let card_no = sample_card("test.denied");
+    let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await;
+
+    assert!(
+        result.is_err(),
+        "peer fuera de allowlist debe ser rechazado, got: {:?}",
+        result.is_ok()
+    );
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado");
+    }
+}
+
+/// Denylist gate: A configura `policy` con un peer en la denylist.
+/// Modo abierto para todo lo demás (sin allowlist), pero el peer
+/// baneado es rechazado aún teniendo Ed25519 válida y peer_id que
+/// derivaría limpio del Noise handshake.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn libp2p_handshake_denylist_blocks_listed_peer() {
+    let banned_kp = Keypair::generate_ed25519();
+    let banned_peer = banned_kp.public().to_peer_id();
+    let other_kp = Keypair::generate_ed25519();
+
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: None,
+                net: None,
+                policy: Some(PeerPolicy::from_sets(
+                    None, // sin allowlist (abierto)
+                    [banned_peer].into_iter().collect(),
+                )),
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = server.sessions();
+
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer = server_net.peer_id;
+    let actual = server_net
+        .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
+        .await;
+    let mut full = actual.clone();
+    full.push(Protocol::P2p(server_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    // Cliente baneado: connect debe fallar.
+    let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
+    banned_net.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let card_x = sample_card("test.banned");
+    let result = connect_libp2p(&banned_net, server_peer, card_x, None, &banned_kp).await;
+    assert!(
+        result.is_err(),
+        "peer en denylist debe ser rechazado, got Ok"
+    );
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 0, "el peer baneado no debería tener sesión");
+    }
+
+    // Cliente no-baneado pasa.
+    let other_net = BrahmanNet::with_keypair(other_kp.clone()).unwrap();
+    other_net.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let card_ok = sample_card("test.other");
+    let mut other_client = connect_libp2p(&other_net, server_peer, card_ok, None, &other_kp)
+        .await
+        .expect("peer fuera de denylist debe pasar");
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 1, "sesión del peer no-baneado registrada");
+    }
+    other_client.farewell().await.ok();
+}
+
+/// Swarm-level deny via `PeerPolicy::attach_to_net`: cuando la deny
+/// se aplica al swarm vía `block_list`, el peer baneado es rechazado
+/// en el dial — la conexión TCP/Noise nunca completa, así que el
+/// cliente nunca llega siquiera a mandar el Hello. Más eficiente que
+/// el handshake-level deny.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn swarm_level_deny_blocks_before_noise() {
+    let banned_kp = Keypair::generate_ed25519();
+    let banned_peer = banned_kp.public().to_peer_id();
+
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+    let policy = card_handshake::peer_policy::PeerPolicy::from_sets(
+        None,
+        [banned_peer].into_iter().collect(),
+    );
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: None,
+                net: None,
+                policy: Some(policy.clone()),
+            },
+        )
+        .unwrap(),
+    );
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer = server_net.peer_id;
+
+    // ATTACH: la deny se proyecta al swarm. Es lo nuevo de este
+    // commit — sin esta llamada, el deny seguiría aplicando sólo
+    // al nivel de handshake brahman (lo que también funciona pero
+    // gasta un round-trip Noise).
+    policy.attach_to_net(server_net.clone());
+
+    let actual = server_net
+        .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
+        .await;
+    let mut full = actual.clone();
+    full.push(Protocol::P2p(server_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    // Cliente baneado intenta dial + handshake. Con swarm-level
+    // deny, la conexión libp2p ni siquiera completa: `connect_libp2p`
+    // falla con error de open_stream (peer inalcanzable / connection
+    // refused) en lugar del Unauthorized del handshake-level path.
+    let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
+    banned_net.dial(full.clone());
+
+    let card = sample_card("test.swarm_banned");
+    // Timeout corto: si el block falla, el handshake completaría
+    // rápido en localhost. Si funciona, debería fallar el dial casi
+    // instantáneo o colgarse hasta el timeout.
+    let result = tokio::time::timeout(
+        Duration::from_secs(3),
+        connect_libp2p(&banned_net, server_peer, card, None, &banned_kp),
+    )
+    .await;
+
+    match result {
+        Ok(Ok(_)) => panic!("peer baneado a nivel swarm NO debería completar handshake"),
+        Ok(Err(e)) => {
+            // Esperado: error de transporte/stream, no de handshake.
+            tracing::info!(error = %e, "swarm-level deny rechazó como esperado");
+        }
+        Err(_) => {
+            // También aceptable: timeout porque el dial nunca completa.
+            tracing::info!("swarm-level deny → connect timeout (también OK)");
+        }
+    }
+}
+
+/// Multi-key identity: la propiedad fundamental que cierra el
+/// proyecto. El cliente B tiene una identity master estable; el
+/// server A le permite el master_peer en allowlist. B se conecta con
+/// **session1**; pasa. B "rota": genera **session2** distinta, emite
+/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
+/// también — sin que A toque su allowlist.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn identity_cert_allows_session_rotation_without_policy_change() {
+    // Master de B (estable, persistente).
+    let master_kp = Keypair::generate_ed25519();
+    let master_peer = master_kp.public().to_peer_id();
+    let identity = Identity::from_keypair(master_kp);
+
+    // A configura policy: allowlist con master_peer (NO sessions).
+    let tmp = TempDir::new().unwrap();
+    let unix_socket = tmp.path().join("brahman-init.sock");
+    let server = Arc::new(
+        Server::bind(
+            &unix_socket,
+            ServerConfig {
+                init_attached: true,
+                broker: None,
+                net: None,
+                policy: Some(PeerPolicy::from_sets(
+                    Some([master_peer].into_iter().collect()),
+                    std::collections::BTreeSet::new(),
+                )),
+            },
+        )
+        .unwrap(),
+    );
+    let sessions = server.sessions();
+
+    let server_net = Arc::new(BrahmanNet::new().unwrap());
+    let server_peer = server_net.peer_id;
+    let actual = server_net
+        .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
+        .await;
+    let mut full = actual.clone();
+    full.push(Protocol::P2p(server_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
+
+    // ---- Conexión 1: session1 ----
+    let session1_kp = Keypair::generate_ed25519();
+    let cert1 = identity
+        .issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
+        .unwrap();
+    let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
+    net1.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let mut client1 = connect_libp2p_with_cert(
+        &net1,
+        server_peer,
+        sample_card("test.session1"),
+        None,
+        &session1_kp,
+        cert1,
+    )
+    .await
+    .expect("session1 con cert válido del master allowlisted debe pasar");
+
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 1, "session1 registrada");
+    }
+    client1.farewell().await.ok();
+    tokio::time::sleep(Duration::from_millis(100)).await;
+
+    // ---- ROTACIÓN: session2 distinta, mismo master ----
+    let session2_kp = Keypair::generate_ed25519();
+    assert_ne!(
+        session1_kp.public().to_peer_id(),
+        session2_kp.public().to_peer_id(),
+        "test inválido si las sessions son iguales"
+    );
+    let cert2 = identity
+        .issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
+        .unwrap();
+
+    let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
+    net2.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let mut client2 = connect_libp2p_with_cert(
+        &net2,
+        server_peer,
+        sample_card("test.session2"),
+        None,
+        &session2_kp,
+        cert2,
+    )
+    .await
+    .expect(
+        "session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
+    );
+
+    {
+        let s = sessions.lock().await;
+        assert_eq!(s.len(), 1, "session2 registrada");
+    }
+    client2.farewell().await.ok();
+
+    // Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
+    // NO está en la allowlist (porque la allowlist tiene master, no
+    // sessions) DEBE ser rechazada.
+    let session_other = Keypair::generate_ed25519();
+    let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
+    net_other.dial(full.clone());
+    tokio::time::sleep(Duration::from_millis(200)).await;
+
+    let result = connect_libp2p(
+        &net_other,
+        server_peer,
+        sample_card("test.no_cert"),
+        None,
+        &session_other,
+    )
+    .await;
+    assert!(
+        result.is_err(),
+        "sin cert, session_peer_id (no listado) debe ser rechazado"
+    );
+}
diff --git a/02_ruway/chasqui/card-sidecar/Cargo.toml b/02_ruway/chasqui/card-sidecar/Cargo.toml
new file mode 100644
index 0000000..a6be679
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "card-sidecar"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — sidecar reusable: thread + tokio runtime que mantiene viva la sesión de un módulo contra el Init."
+
+[dependencies]
+card-core = { workspace = true }
+card-handshake = { path = "../card-handshake" }
+# Discovery remoto por DHT: el resolver local-first/remote-fallback expone
+# `&BrahmanNet` y `PeerId` en su API pública.
+card-net = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tracing-subscriber = { workspace = true }
+card-wit = { workspace = true }
+chasqui-broker = { path = "../chasqui-broker" }
+tempfile = { workspace = true }
+
+[[example]]
+name = "presence"
+path = "examples/presence.rs"
+
+[[example]]
+name = "presence-conscious"
+path = "examples/presence-conscious.rs"
diff --git a/02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs b/02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs
new file mode 100644
index 0000000..c2ecf69
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs
@@ -0,0 +1,99 @@
+//! `presence-conscious` — módulo brahman que se presenta con su WIT.
+//!
+//! Variante de [`presence`] que toma un path a un archivo `.wit` (default
+//! `shared_wit/protocol.wit` resuelto desde el cwd) y lo parsea con
+//! `brahman-card-wit` antes de spawnear el sidecar. Demuestra el flujo
+//! "módulo consciente": Hello incluye `WitInterface`, el server lo
+//! registra como `ResolvedCard::from_conscious`, y aparece con marker
+//! 🧠 en `brahman-status`.
+//!
+//! Uso:
+//! ```sh
+//! cargo run -p brahman-sidecar --example presence-conscious -- mi-modulo [path/al.wit]
+//! ```
+
+use std::collections::BTreeSet;
+use std::path::PathBuf;
+use std::time::Duration;
+
+use card_core::{
+    ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
+    CARD_SCHEMA_VERSION,
+};
+use card_sidecar::{spawn_with_handle, SidecarConfig};
+
+fn main() {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "info".into()),
+        )
+        .init();
+
+    let mut args = std::env::args().skip(1);
+    let label = args.next().unwrap_or_else(|| "conscious-default".into());
+    let wit_path: PathBuf = args
+        .next()
+        .map(PathBuf::from)
+        .unwrap_or_else(|| PathBuf::from("shared_wit/protocol.wit"));
+
+    let wit = match card_wit::parse_wit_file(&wit_path) {
+        Ok(worlds) => match worlds.into_iter().next() {
+            Some(w) => {
+                eprintln!(
+                    "[{label}] cargado wit: {} / {}",
+                    w.package, w.world
+                );
+                Some(w)
+            }
+            None => {
+                eprintln!("[{label}] {} no declara worlds", wit_path.display());
+                None
+            }
+        },
+        Err(e) => {
+            eprintln!("[{label}] falló parse de {}: {e}", wit_path.display());
+            None
+        }
+    };
+
+    let card = Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.clone(),
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        flow: Flows {
+            input: vec![Flow {
+                name: "in".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+            output: vec![Flow {
+                name: "out".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Default::default()
+    };
+
+    let config = SidecarConfig {
+        card,
+        wit,
+        ping_interval: Duration::from_secs(5),
+    };
+
+    let _handle = spawn_with_handle(config);
+
+    eprintln!("[{label}] sidecar lanzado, durmiendo (Ctrl-C para salir)");
+    std::thread::park();
+}
diff --git a/02_ruway/chasqui/card-sidecar/examples/presence.rs b/02_ruway/chasqui/card-sidecar/examples/presence.rs
new file mode 100644
index 0000000..837f3c6
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/examples/presence.rs
@@ -0,0 +1,70 @@
+//! `presence` — módulo brahman dummy para pruebas y demos.
+//!
+//! Declara una Card mínima con label tomado del primer argumento (default
+//! `presence-default`) y mantiene la sesión viva hasta SIGTERM/SIGINT.
+//! Útil para poblar el broker con sesiones de prueba.
+//!
+//! Uso:
+//! ```sh
+//! cargo run -p brahman-sidecar --example presence -- mi-modulo
+//! ```
+
+use std::collections::BTreeSet;
+use std::time::Duration;
+
+use card_core::{
+    ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
+    CARD_SCHEMA_VERSION,
+};
+use card_sidecar::{spawn_with_handle, SidecarConfig};
+
+fn main() {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "info".into()),
+        )
+        .init();
+
+    let label = std::env::args()
+        .nth(1)
+        .unwrap_or_else(|| "presence-default".into());
+
+    let card = Card {
+        schema_version: CARD_SCHEMA_VERSION,
+        id: Ulid::new(),
+        label: label.clone(),
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        provides: BTreeSet::new(),
+        requires: BTreeSet::new(),
+        flow: Flows {
+            input: vec![Flow {
+                name: "in".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+            output: vec![Flow {
+                name: "out".into(),
+                ty: TypeRef::Primitive {
+                    name: "json".into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Default::default()
+    };
+
+    let _handle = spawn_with_handle(SidecarConfig {
+        card,
+        wit: None,
+        ping_interval: Duration::from_secs(5),
+    });
+
+    eprintln!("presence({label}): sidecar lanzado, durmiendo (Ctrl-C para salir)");
+    std::thread::park();
+}
diff --git a/02_ruway/chasqui/card-sidecar/src/discovery.rs b/02_ruway/chasqui/card-sidecar/src/discovery.rs
new file mode 100644
index 0000000..6f9fb1b
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/src/discovery.rs
@@ -0,0 +1,453 @@
+//! `brahman-sidecar::discovery` — API reusable para que un módulo
+//! consumer encuentre proveedores vivos vía broker, sin hardcodear
+//! sockets ni reimplementar el patrón a mano.
+//!
+//! Es la generalización de `discover_producer_socket` del CLI
+//! `chasqui attract --remote`: declarás el `TypeRef` que querés
+//! consumir y el broker te empuja un `MatchEvent::Available` con el
+//! `producer_service_socket` del primer proveedor matched.
+//!
+//! Pipeline:
+//! 1. `build_consumer_card(label, flow_name, type_name)` arma una
+//!    Card mínima (Ente, Oneshot, Virtual) con un input flow.
+//! 2. `await_provider(card, timeout)` se conecta al brahman-init,
+//!    espera hasta `timeout` por `MatchEvent::Available`, devuelve
+//!    el socket del proveedor electo, y envía Farewell.
+//! 3. Para mundos blocking (CLIs, tests, std-thread loops) hay
+//!    `await_provider_blocking` que arma su propio runtime
+//!    `current_thread`.
+//!
+//! Quién elige al proveedor es el broker, no este módulo. Si el
+//! broker tiene `priority_contexts` activo, podés cambiar de
+//! proveedor sin tocar el consumer; el matching dinámico se respeta.
+
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use card_core::{
+    Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef, WitInterface,
+};
+use card_net::{BrahmanNet, PeerId};
+use card_handshake::client::{Client, ClientError};
+use card_handshake::messages::MatchEventKind;
+use card_handshake::network::{connect_libp2p, LibP2pHandshakeStream, NetworkError};
+use card_handshake::transport;
+use tracing::warn;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ConsumerError {
+    #[error("no se pudo conectar al init en {socket}: {source}")]
+    Connect {
+        socket: PathBuf,
+        #[source]
+        source: ClientError,
+    },
+    #[error("error en cliente brahman: {0}")]
+    Client(#[from] ClientError),
+    #[error("timeout {timeout:?} sin proveedor disponible para flow '{flow}' (type '{type_ref}')")]
+    NoProvider {
+        flow: String,
+        type_ref: String,
+        timeout: Duration,
+    },
+    #[error("no se pudo crear runtime tokio: {0}")]
+    Runtime(String),
+    #[error("la Card consumer no declara ningún input flow para resolver")]
+    NoInputFlow,
+    #[error(
+        "el DHT no anuncia proveedores para flow '{flow}' (type '{type_ref}')"
+    )]
+    NoRemoteProviders { flow: String, type_ref: String },
+    #[error(
+        "todos los {} peers remotos fallaron al conectar; último error: {source}",
+        peers.len()
+    )]
+    AllPeersFailed {
+        peers: Vec<PeerId>,
+        #[source]
+        source: NetworkError,
+    },
+}
+
+/// Construye una Card mínima de consumer que declara un input flow
+/// con el `TypeRef::Primitive { name }` solicitado. Usá esto para
+/// el caso común; si necesitás algo más rico (output flows,
+/// permissions, references) construí la Card a mano y pasala a
+/// [`await_provider`] directamente.
+pub fn build_consumer_card(
+    consumer_label: impl Into<String>,
+    flow_name: impl Into<String>,
+    type_name: impl Into<String>,
+) -> Card {
+    Card {
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Oneshot,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        flow: Flows {
+            input: vec![Flow {
+                name: flow_name.into(),
+                ty: TypeRef::Primitive {
+                    name: type_name.into(),
+                },
+                pin_to: None,
+            }],
+            output: vec![],
+        },
+        ..Card::new(consumer_label)
+    }
+}
+
+/// Conecta al brahman-init, registra `consumer_card`, espera el
+/// primer `MatchEvent::Available` y devuelve el `producer_service_socket`
+/// que el broker emitió. Cierra la sesión con Farewell antes de
+/// retornar (best-effort).
+///
+/// La `consumer_card` debe declarar al menos un `flow.input`; si no,
+/// el broker no puede hacer matching y el await siempre dará timeout.
+pub async fn await_provider(
+    consumer_card: Card,
+    timeout: Duration,
+) -> Result<PathBuf, ConsumerError> {
+    let init_path = transport::default_socket_path();
+
+    // Capturamos descriptor para el mensaje de error antes de mover
+    // la card al cliente.
+    let (flow_name, type_ref_name) = describe_first_input(&consumer_card);
+
+    let mut client = Client::connect(&init_path, consumer_card)
+        .await
+        .map_err(|source| ConsumerError::Connect {
+            socket: init_path.clone(),
+            source,
+        })?;
+
+    let deadline = Instant::now() + timeout;
+    let socket = loop {
+        let remaining = deadline.saturating_duration_since(Instant::now());
+        if remaining.is_zero() {
+            break None;
+        }
+        match client.await_event(remaining).await? {
+            Some(ev) if ev.kind == MatchEventKind::Available => {
+                break ev.producer_service_socket;
+            }
+            Some(_) => continue, // Lost u otros: seguir esperando hasta el deadline
+            None => break None,
+        }
+    };
+
+    let _ = client.farewell().await; // best-effort cleanup
+
+    socket.ok_or(ConsumerError::NoProvider {
+        flow: flow_name,
+        type_ref: type_ref_name,
+        timeout,
+    })
+}
+
+/// Wrapper bloqueante de [`await_provider`]. Crea un runtime tokio
+/// `current_thread` efímero y bloquea el thread llamador. Útil para
+/// CLIs, tests y módulos std-thread (p. ej. el frontend GPUI antes
+/// de tener su propio runtime async).
+pub fn await_provider_blocking(
+    consumer_card: Card,
+    timeout: Duration,
+) -> Result<PathBuf, ConsumerError> {
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_io()
+        .enable_time()
+        .build()
+        .map_err(|e| ConsumerError::Runtime(e.to_string()))?;
+
+    rt.block_on(await_provider(consumer_card, timeout))
+}
+
+// =====================================================================
+// Discovery local-first / remoto (DHT) — capa de consumidor
+// =====================================================================
+
+/// Discovery DHT crudo, reexportado desde `card-handshake` para que un
+/// consumidor que ya usa `card-sidecar` pueda buscar proveedores remotos
+/// por `(flow_name, TypeRef)` sin alcanzar las internas del handshake.
+pub use card_handshake::network::find_remote_providers;
+
+/// Dónde vive el proveedor elegido para un flow.
+///
+/// El broker local (vía `await_provider`) entrega un socket Unix listo
+/// para consumir; el DHT remoto entrega `PeerId`s que el caller debe
+/// dial-ar y sub-handshakear con `card_handshake::network::connect_libp2p`.
+#[derive(Debug, Clone)]
+pub enum ProviderLocation {
+    /// Proveedor local: socket de servicio resuelto por el broker del init.
+    Local(PathBuf),
+    /// Proveedores remotos anunciados en el DHT (peers a dial-ar).
+    Remote(Vec<PeerId>),
+}
+
+/// Resuelve un proveedor para el **primer input flow** de `consumer_card`,
+/// **local primero, DHT remoto como fallback**.
+///
+/// 1. Pregunta al broker del init local (`await_provider`) hasta
+///    `local_timeout`. Si hay match → [`ProviderLocation::Local`].
+/// 2. Si no hay proveedor local (timeout) ni init local alcanzable
+///    (socket ausente), consulta el DHT vía `net` y devuelve
+///    [`ProviderLocation::Remote`] con los `PeerId`s que anuncian el flow
+///    (lista posiblemente vacía si nadie lo provee en la malla).
+///
+/// Esto es el cableado que faltaba entre el discovery local (broker) y el
+/// discovery remoto (DHT de `card-net`, ya cableado en el handshake): un
+/// consumidor pide "un proveedor para el tipo X" y obtiene el más cercano
+/// disponible sin saber a priori si vive en esta máquina o en la red.
+pub async fn resolve_provider(
+    consumer_card: Card,
+    net: &BrahmanNet,
+    local_timeout: Duration,
+) -> Result<ProviderLocation, ConsumerError> {
+    // Necesitamos el flow a resolver antes de mover la card al broker.
+    let (flow_name, ty) = match consumer_card.flow.input.first() {
+        Some(f) => (f.name.clone(), f.ty.clone()),
+        None => return Err(ConsumerError::NoInputFlow),
+    };
+
+    // 1) Local primero.
+    match await_provider(consumer_card, local_timeout).await {
+        Ok(socket) => return Ok(ProviderLocation::Local(socket)),
+        // Sin proveedor local (timeout) o sin init local alcanzable:
+        // caemos al DHT. Cualquier otro error sí se propaga.
+        Err(ConsumerError::NoProvider { .. }) | Err(ConsumerError::Connect { .. }) => {}
+        Err(other) => return Err(other),
+    }
+
+    // 2) Fallback remoto por DHT.
+    let peers = find_remote_providers(net, &flow_name, &ty).await;
+    Ok(ProviderLocation::Remote(peers))
+}
+
+/// Consume una `Card` a través de un proveedor remoto descubierto por DHT.
+///
+/// Recorre `peers` en orden, intentando `connect_libp2p` con cada uno hasta
+/// que uno establezca handshake. La sesión devuelta es un `Client` idéntico
+/// al del path Unix: `ping`, `await_event`, `farewell` funcionan igual; el
+/// stream subyacente es libp2p convertido vía `tokio_util::compat`.
+///
+/// La keypair que se usa para firmar el handshake es la del propio `net`
+/// (`BrahmanNet::keypair`) — la misma que autentica la conexión Noise, así
+/// que el `peer_id` del Hello coincide con el del transporte y el server
+/// no rechaza por `Unauthorized`.
+///
+/// Errores:
+/// * [`ConsumerError::NoRemoteProviders`] si `peers` está vacío.
+/// * [`ConsumerError::AllPeersFailed`] si ningún peer aceptó el handshake;
+///   el `source` es el último `NetworkError` observado.
+///
+/// Atajo de uso típico tras `resolve_provider`:
+/// ```ignore
+/// match resolve_provider(card.clone(), &net, timeout).await? {
+///     ProviderLocation::Local(socket) => {
+///         Client::connect_with(&socket, card, wit).await?
+///     }
+///     ProviderLocation::Remote(peers) => {
+///         consume_remote(&net, card, wit, peers).await?
+///     }
+/// };
+/// ```
+pub async fn consume_remote(
+    net: &BrahmanNet,
+    consumer_card: Card,
+    wit: Option<WitInterface>,
+    peers: Vec<PeerId>,
+) -> Result<Client<LibP2pHandshakeStream>, ConsumerError> {
+    if peers.is_empty() {
+        let (flow, type_ref) = describe_first_input(&consumer_card);
+        return Err(ConsumerError::NoRemoteProviders { flow, type_ref });
+    }
+
+    let keypair = net.keypair();
+    let mut last_error: Option<NetworkError> = None;
+
+    for peer in &peers {
+        match connect_libp2p(net, *peer, consumer_card.clone(), wit.clone(), &keypair).await {
+            Ok(client) => return Ok(client),
+            Err(e) => {
+                warn!(
+                    target: "card_sidecar",
+                    peer = %peer,
+                    error = %e,
+                    "dial al peer remoto falló, intento siguiente",
+                );
+                last_error = Some(e);
+            }
+        }
+    }
+
+    Err(ConsumerError::AllPeersFailed {
+        peers,
+        source: last_error.expect("el loop garantiza al menos un error si peers no era vacío"),
+    })
+}
+
+/// Conecta al brahman-init con una Card observer (sin inputs ni
+/// outputs) y pide la lista de sesiones activas. Útil para
+/// herramientas de observabilidad (broker-explorer, CLIs).
+///
+/// El observer se identifica con `observer_label`. La sesión se
+/// cierra con Farewell antes de retornar (best-effort).
+pub async fn list_sessions(
+    observer_label: impl Into<String>,
+) -> Result<card_handshake::messages::SessionList, ConsumerError> {
+    let init_path = transport::default_socket_path();
+    // Card mínima sin flow.input/output: el observer no participa en
+    // matching, sólo establece sesión para poder consultar.
+    let card = Card {
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Oneshot,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        flow: Flows {
+            input: vec![],
+            output: vec![],
+        },
+        ..Card::new(observer_label)
+    };
+
+    let mut client = Client::connect(&init_path, card)
+        .await
+        .map_err(|source| ConsumerError::Connect {
+            socket: init_path.clone(),
+            source,
+        })?;
+
+    let list = client.list_sessions().await?;
+    let _ = client.farewell().await;
+    Ok(list)
+}
+
+/// Wrapper bloqueante de [`list_sessions`]. Idéntico patrón a
+/// `await_provider_blocking`: runtime current_thread efímero.
+pub fn list_sessions_blocking(
+    observer_label: impl Into<String>,
+) -> Result<card_handshake::messages::SessionList, ConsumerError> {
+    let label = observer_label.into();
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_io()
+        .enable_time()
+        .build()
+        .map_err(|e| ConsumerError::Runtime(e.to_string()))?;
+    rt.block_on(list_sessions(label))
+}
+
+/// Análogo a `list_sessions` pero pide los matches activos del
+/// broker. La Card observer es la misma forma minimalista (sin
+/// flow.input/output) — el endpoint no requiere participar en
+/// matching.
+pub async fn list_matches(
+    observer_label: impl Into<String>,
+) -> Result<card_handshake::messages::MatchList, ConsumerError> {
+    let init_path = transport::default_socket_path();
+    let card = Card {
+        payload: Payload::Virtual,
+        supervision: Supervision::OneShot,
+        lifecycle: Lifecycle::Oneshot,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        flow: Flows {
+            input: vec![],
+            output: vec![],
+        },
+        ..Card::new(observer_label)
+    };
+
+    let mut client = Client::connect(&init_path, card)
+        .await
+        .map_err(|source| ConsumerError::Connect {
+            socket: init_path.clone(),
+            source,
+        })?;
+
+    let list = client.list_matches().await?;
+    let _ = client.farewell().await;
+    Ok(list)
+}
+
+/// Wrapper bloqueante de [`list_matches`].
+pub fn list_matches_blocking(
+    observer_label: impl Into<String>,
+) -> Result<card_handshake::messages::MatchList, ConsumerError> {
+    let label = observer_label.into();
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_io()
+        .enable_time()
+        .build()
+        .map_err(|e| ConsumerError::Runtime(e.to_string()))?;
+    rt.block_on(list_matches(label))
+}
+
+fn describe_first_input(card: &Card) -> (String, String) {
+    match card.flow.input.first() {
+        Some(flow) => {
+            let type_name = match &flow.ty {
+                TypeRef::Primitive { name } => name.clone(),
+                TypeRef::Wit { package, name, .. } => format!("{package}#{name}"),
+            };
+            (flow.name.clone(), type_name)
+        }
+        None => ("(sin input)".into(), "(sin tipo)".into()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use card_core::ulid::Ulid;
+
+    #[test]
+    fn builder_sets_input_flow_with_primitive_type() {
+        let c = build_consumer_card("chasqui.cli", "embed-result", "json");
+        assert_eq!(c.label, "chasqui.cli");
+        assert_eq!(c.kind, CardKind::Ente);
+        assert!(matches!(c.lifecycle, Lifecycle::Oneshot));
+        assert!(matches!(c.supervision, Supervision::OneShot));
+        assert_eq!(c.flow.input.len(), 1);
+        let f = &c.flow.input[0];
+        assert_eq!(f.name, "embed-result");
+        match &f.ty {
+            TypeRef::Primitive { name } => assert_eq!(name, "json"),
+            _ => panic!("expected primitive type"),
+        }
+        assert!(c.flow.output.is_empty());
+        // El builder asigna un id real (no nil) — fundamental para que
+        // el broker no colisione con otros consumers.
+        assert!(c.id != Ulid::nil(), "consumer card id no debe ser nil");
+    }
+
+    #[test]
+    fn builder_assigns_distinct_ids_per_call() {
+        let a = build_consumer_card("a", "f", "t");
+        let b = build_consumer_card("a", "f", "t");
+        assert_ne!(a.id, b.id, "cada Card debería tener id propio");
+    }
+
+    #[test]
+    fn describe_falls_back_when_no_input_flow() {
+        let mut c = build_consumer_card("x", "f", "t");
+        c.flow.input.clear();
+        let (flow, ty) = describe_first_input(&c);
+        assert_eq!(flow, "(sin input)");
+        assert_eq!(ty, "(sin tipo)");
+    }
+
+    #[test]
+    fn describe_formats_wit_type() {
+        let mut c = build_consumer_card("x", "f", "t");
+        c.flow.input[0].ty = TypeRef::Wit {
+            package: "brahman:dht".into(),
+            interface: None,
+            name: "entity-result".into(),
+        };
+        let (_, ty) = describe_first_input(&c);
+        assert_eq!(ty, "brahman:dht#entity-result");
+    }
+}
diff --git a/02_ruway/chasqui/card-sidecar/src/lib.rs b/02_ruway/chasqui/card-sidecar/src/lib.rs
new file mode 100644
index 0000000..1c86df8
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/src/lib.rs
@@ -0,0 +1,255 @@
+//! `brahman-sidecar` — boilerplate del cliente brahman extraído.
+//!
+//! Cualquier módulo que quiera presentarse al Init brahman pero que tenga
+//! su propio runtime (GPUI, current_thread tokio, std-thread loop, etc.)
+//! puede llamar [`spawn`] con su [`card_core::Card`]. Eso arma un
+//! thread aparte con un runtime tokio current_thread, conecta al Init,
+//! y mantiene la sesión viva con pings periódicos.
+//!
+//! Si el Init no está disponible, el thread loggea y termina — el módulo
+//! sigue funcionando standalone.
+//!
+//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si querés
+//! capturar la salida del thread (por ejemplo para test), usá
+//! [`spawn_with_handle`] que devuelve un `JoinHandle`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+pub mod discovery;
+pub use discovery::{
+    await_provider, await_provider_blocking, build_consumer_card, consume_remote, list_matches,
+    list_matches_blocking, list_sessions, list_sessions_blocking, resolve_provider, ConsumerError,
+    ProviderLocation,
+};
+
+use std::collections::HashMap;
+use std::sync::{mpsc, Arc, Mutex};
+use std::thread::JoinHandle;
+use std::time::Duration;
+
+use card_core::{ulid::Ulid, Card, WitInterface};
+use card_handshake::{client::Client, transport};
+use tokio::task::AbortHandle;
+use tracing::{info, warn};
+
+/// Período entre pings al Init.
+pub const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(30);
+
+/// Configuración del sidecar.
+#[derive(Debug, Clone)]
+pub struct SidecarConfig {
+    /// Card que se presenta al Init.
+    pub card: Card,
+    /// WIT interface opcional. Si es `Some`, el módulo se registra como
+    /// "consciente" (`ResolvedCard::from_conscious`).
+    pub wit: Option<WitInterface>,
+    /// Período entre pings.
+    pub ping_interval: Duration,
+}
+
+impl SidecarConfig {
+    /// Configuración agnóstica con defaults razonables (sin WIT, ping 30s).
+    pub fn new(card: Card) -> Self {
+        Self {
+            card,
+            wit: None,
+            ping_interval: DEFAULT_PING_INTERVAL,
+        }
+    }
+
+    /// Configuración consciente con WIT extraída.
+    pub fn with_wit(mut self, wit: WitInterface) -> Self {
+        self.wit = Some(wit);
+        self
+    }
+}
+
+/// Spawn fire-and-forget agnóstico. Para módulos conscientes usá
+/// [`spawn_conscious`] o [`spawn_with_handle`] con un `SidecarConfig`
+/// personalizado.
+pub fn spawn(card: Card) {
+    if let Err(e) = spawn_with_handle(SidecarConfig::new(card)) {
+        warn!(error = %e, "no se pudo spawnear el sidecar brahman");
+    }
+}
+
+/// Spawn fire-and-forget con WIT. Idéntico a [`spawn`] pero el módulo se
+/// registra como consciente en el broker.
+pub fn spawn_conscious(card: Card, wit: WitInterface) {
+    if let Err(e) = spawn_with_handle(SidecarConfig::new(card).with_wit(wit)) {
+        warn!(error = %e, "no se pudo spawnear el sidecar brahman");
+    }
+}
+
+/// Spawn devolviendo el `JoinHandle` para tests o cleanup explícito.
+pub fn spawn_with_handle(config: SidecarConfig) -> std::io::Result<JoinHandle<()>> {
+    std::thread::Builder::new()
+        .name("brahman-sidecar".into())
+        .spawn(move || run_thread(config))
+}
+
+// =====================================================================
+// SidecarPool — un solo runtime tokio compartido por N sesiones
+// =====================================================================
+
+/// Pool consolidado: un único thread con un runtime tokio
+/// `current_thread` que hostea N tasks de sidecar simultáneas.
+///
+/// Para módulos con muchas sesiones (p. ej. `chasqui daemon` publicando
+/// 50+ Mónadas), evita el costo de tener un thread+runtime por cada
+/// sesión.
+///
+/// **API**:
+/// - `SidecarPool::new()` crea el pool (spawn del thread runtime).
+/// - `pool.spawn(card)` añade una sesión sin WIT.
+/// - `pool.spawn_conscious(card, wit)` añade una sesión con WIT.
+/// - `pool.spawn_with_config(config)` para configuración custom.
+///
+/// El pool se mantiene vivo mientras exista. Si el `SidecarPool`
+/// se dropea, el thread interno termina y todas las sesiones cierran.
+pub struct SidecarPool {
+    handle: tokio::runtime::Handle,
+    /// Sesiones vivas indexadas por `Card.id`. Permite que un nuevo
+    /// `spawn` con el mismo id aborte la sesión previa — útil cuando
+    /// un módulo (p. ej. `chasqui daemon`) re-publica una Mónada cuya
+    /// composición cambió.
+    sessions: Arc<Mutex<HashMap<Ulid, AbortHandle>>>,
+    _thread: JoinHandle<()>,
+}
+
+impl SidecarPool {
+    /// Crea un pool nuevo. Bloquea hasta que el runtime esté listo.
+    pub fn new() -> std::io::Result<Self> {
+        let (handle_tx, handle_rx) = mpsc::sync_channel::<tokio::runtime::Handle>(0);
+        let thread = std::thread::Builder::new()
+            .name("brahman-sidecar-pool".into())
+            .spawn(move || {
+                let rt = match tokio::runtime::Builder::new_current_thread()
+                    .enable_io()
+                    .enable_time()
+                    .build()
+                {
+                    Ok(rt) => rt,
+                    Err(e) => {
+                        warn!(error = %e, "tokio runtime falló — pool muerto");
+                        return;
+                    }
+                };
+                if handle_tx.send(rt.handle().clone()).is_err() {
+                    return;
+                }
+                // Mantenemos el runtime vivo mientras existan tasks.
+                rt.block_on(std::future::pending::<()>());
+            })?;
+        let handle = handle_rx
+            .recv()
+            .map_err(|_| std::io::Error::other("pool runtime no respondió"))?;
+        Ok(Self {
+            handle,
+            sessions: Arc::new(Mutex::new(HashMap::new())),
+            _thread: thread,
+        })
+    }
+
+    /// Añade una sesión agnóstica al pool (sin WIT).
+    pub fn spawn(&self, card: Card) {
+        self.spawn_with_config(SidecarConfig::new(card));
+    }
+
+    /// Añade una sesión consciente (con WIT) al pool.
+    pub fn spawn_conscious(&self, card: Card, wit: WitInterface) {
+        self.spawn_with_config(SidecarConfig::new(card).with_wit(wit));
+    }
+
+    /// Añade una sesión con configuración custom.
+    ///
+    /// Si ya existía una sesión para el mismo `Card.id`, la previa
+    /// se aborta antes de spawnear la nueva. Esto hace `spawn`
+    /// idempotente respecto al id: re-publicar una Mónada cuya
+    /// composición cambió "refresca" la sesión en el broker.
+    pub fn spawn_with_config(&self, config: SidecarConfig) {
+        let card_id = config.card.id;
+        let join = self.handle.spawn(run_client(config));
+        let abort = join.abort_handle();
+        if let Ok(mut sessions) = self.sessions.lock() {
+            if let Some(prev) = sessions.insert(card_id, abort) {
+                prev.abort();
+            }
+        }
+    }
+
+    /// Cierra explícitamente la sesión asociada a `card_id`. No-op si
+    /// no había sesión registrada.
+    pub fn drop_session(&self, card_id: Ulid) {
+        if let Ok(mut sessions) = self.sessions.lock() {
+            if let Some(abort) = sessions.remove(&card_id) {
+                abort.abort();
+            }
+        }
+    }
+
+    /// Cantidad actual de sesiones vivas (estimada — puede haber
+    /// drift transitorio entre abort y limpieza).
+    pub fn live_sessions(&self) -> usize {
+        self.sessions.lock().map(|s| s.len()).unwrap_or(0)
+    }
+}
+
+impl Default for SidecarPool {
+    fn default() -> Self {
+        Self::new().expect("falló SidecarPool::new")
+    }
+}
+
+fn run_thread(config: SidecarConfig) {
+    let rt = match tokio::runtime::Builder::new_current_thread()
+        .enable_io()
+        .enable_time()
+        .build()
+    {
+        Ok(rt) => rt,
+        Err(e) => {
+            warn!(error = %e, "tokio runtime falló");
+            return;
+        }
+    };
+    rt.block_on(run_client(config));
+}
+
+/// Bucle async del sidecar. Público para que `SidecarPool` lo use vía
+/// `handle.spawn(run_client(...))` desde código externo al runtime.
+pub async fn run_client(config: SidecarConfig) {
+    let path = transport::default_socket_path();
+    let conscious = config.wit.is_some();
+    let mut client = match Client::connect_with(&path, config.card, config.wit).await {
+        Ok(c) => {
+            info!(
+                target: "card_sidecar",
+                session = %c.session(),
+                init_attached = c.server_info().init_attached,
+                server = %c.server_info().server_version,
+                conscious,
+                "attached"
+            );
+            c
+        }
+        Err(e) => {
+            warn!(
+                target: "card_sidecar",
+                error = %e,
+                socket = %path.display(),
+                "no conectado"
+            );
+            return;
+        }
+    };
+
+    loop {
+        tokio::time::sleep(config.ping_interval).await;
+        if let Err(e) = client.ping().await {
+            warn!(target: "card_sidecar", error = %e, "ping falló — terminando sidecar");
+            return;
+        }
+    }
+}
diff --git a/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs b/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs
new file mode 100644
index 0000000..0d39488
--- /dev/null
+++ b/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs
@@ -0,0 +1,256 @@
+//! Integración del resolver `card_sidecar::discovery::resolve_provider`:
+//! prueba el camino **local-first / remote-fallback**. Sin init local
+//! alcanzable (socket bogus), el resolver debe caer al DHT y descubrir un
+//! proveedor remoto que anunció su output flow.
+//!
+//! Reusa el harness de `card-handshake/tests/network_discovery.rs`: un Node A
+//! con Server + `BrahmanNet` + Card-con-output (el Server llama
+//! `announce_outputs` al registrar → `start_providing` en el DHT), y un Node B
+//! que dial-a a A y resuelve vía el DHT compartido.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use card_core::{
+    Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
+};
+use card_handshake::network::run_libp2p_accept_loop;
+use card_handshake::server::{Server, ServerConfig};
+use card_net::{BrahmanNet, Multiaddr, Protocol};
+use card_sidecar::discovery::{
+    build_consumer_card, consume_remote, resolve_provider, ConsumerError, ProviderLocation,
+};
+use chasqui_broker::{Broker, BrokerConfig};
+use tempfile::TempDir;
+use tokio::sync::Mutex;
+
+/// Card de proveedor con un único output flow `(flow_name, type_name)`.
+fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
+    Card {
+        payload: Payload::Virtual,
+        supervision: Supervision::Delegate,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        flow: Flows {
+            input: vec![],
+            output: vec![Flow {
+                name: flow_name.into(),
+                ty: TypeRef::Primitive {
+                    name: type_name.into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Card::new(label)
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn resolve_provider_cae_a_dht_cuando_no_hay_local() {
+    // Forzar miss local: que `await_provider` falle rápido por socket ausente
+    // (nextest aísla cada test en su propio proceso, así que la env no se filtra).
+    std::env::set_var("BRAHMAN_INIT_SOCKET", "/nonexistent/brahman-init-test.sock");
+
+    // ---- Node A: Server + net + Card con output (anuncia al DHT) ----
+    let tmp = TempDir::new().unwrap();
+    let a_unix = tmp.path().join("a.sock");
+    let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let a_net = Arc::new(BrahmanNet::new().unwrap());
+    let a_peer = a_net.peer_id;
+
+    let a_server = Arc::new(
+        Server::bind(
+            &a_unix,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(a_broker.clone()),
+                net: Some(a_net.clone()), // ← el Server anuncia los outputs al DHT
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+
+    let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
+    let a_addr = a_net.listen(listen_addr).await;
+    let mut a_full = a_addr.clone();
+    a_full.push(Protocol::P2p(a_peer));
+
+    tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
+    {
+        let s = a_server.clone();
+        tokio::spawn(async move {
+            loop {
+                match s.accept_one().await {
+                    Ok(session) => {
+                        tokio::spawn(async move {
+                            let _ = session.handle().await;
+                        });
+                    }
+                    Err(_) => break,
+                }
+            }
+        });
+    }
+
+    // A registra una Card con output "monad-list":json → start_providing.
+    let card = provider_card("test.engine_remote", "monad-list", "json");
+    let mut local_client = card_handshake::client::Client::connect(&a_unix, card)
+        .await
+        .expect("registro local en A");
+
+    // ---- Node B: net que dial-a a A y comparte DHT ----
+    let b_net = BrahmanNet::new().unwrap();
+    b_net.dial(a_full.clone());
+    // Margen para que Identify popule la routing table de Kad.
+    tokio::time::sleep(Duration::from_millis(500)).await;
+
+    // resolve_provider: sin init local (socket bogus) → fallback al DHT.
+    let consumer = build_consumer_card("test.consumer", "monad-list", "json");
+    let loc = resolve_provider(consumer, &b_net, Duration::from_millis(200))
+        .await
+        .expect("resolve_provider no debe errar");
+
+    match loc {
+        ProviderLocation::Remote(peers) => assert!(
+            peers.contains(&a_peer),
+            "el fallback DHT debería descubrir a A; encontrados: {:?}, esperado: {}",
+            peers,
+            a_peer
+        ),
+        ProviderLocation::Local(s) => {
+            panic!("esperaba Remote (no hay init local), obtuve Local({:?})", s)
+        }
+    }
+
+    local_client.farewell().await.ok();
+}
+
+/// End-to-end del follow-up de Nivel 3 (CIERRE §3.2): un nodo descubre por
+/// DHT a un proveedor remoto y CONSUME — abre handshake firmado por libp2p,
+/// pinea y se despide — sin un init local de por medio. Es el patrón de
+/// `jalar_a_traves_de_un_relay` aplicado al consumer-side de chasqui.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn consume_remote_abre_handshake_firmado_via_libp2p() {
+    // Mismo truco que el test de arriba: forzar miss del init local para
+    // que el resolver caiga directo al DHT.
+    std::env::set_var(
+        "BRAHMAN_INIT_SOCKET",
+        "/nonexistent/brahman-init-consume.sock",
+    );
+
+    // ---- Node A: Server + net; al registrar la Card con output, el
+    //              Server llama `announce_outputs` y publica la key
+    //              `(monad-list, json)` en el DHT.
+    let tmp = TempDir::new().unwrap();
+    let a_unix = tmp.path().join("a.sock");
+    let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
+    let a_net = Arc::new(BrahmanNet::new().unwrap());
+    let a_peer = a_net.peer_id;
+
+    let a_server = Arc::new(
+        Server::bind(
+            &a_unix,
+            ServerConfig {
+                init_attached: true,
+                broker: Some(a_broker.clone()),
+                net: Some(a_net.clone()),
+                policy: None,
+            },
+        )
+        .unwrap(),
+    );
+
+    let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
+    let a_addr = a_net.listen(listen_addr).await;
+    let mut a_full = a_addr.clone();
+    a_full.push(Protocol::P2p(a_peer));
+
+    // A acepta tanto las sesiones libp2p (handshake firmado entrante) como
+    // las locales por socket Unix (necesarias para registrar la Card que
+    // dispara el `announce_outputs`).
+    tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
+    {
+        let s = a_server.clone();
+        tokio::spawn(async move {
+            loop {
+                match s.accept_one().await {
+                    Ok(session) => {
+                        tokio::spawn(async move {
+                            let _ = session.handle().await;
+                        });
+                    }
+                    Err(_) => break,
+                }
+            }
+        });
+    }
+
+    let provider = provider_card("test.engine_remote_consume", "monad-list", "json");
+    let mut local_provider_client = card_handshake::client::Client::connect(&a_unix, provider)
+        .await
+        .expect("registro local del provider en A");
+
+    // ---- Node B: net independiente; dial-a A para compartir DHT.
+    let b_net = BrahmanNet::new().unwrap();
+    b_net.dial(a_full.clone());
+    tokio::time::sleep(Duration::from_millis(500)).await;
+
+    // Descubrir.
+    let consumer = build_consumer_card("test.consumer_remote", "monad-list", "json");
+    let loc = resolve_provider(consumer.clone(), &b_net, Duration::from_millis(200))
+        .await
+        .expect("resolve_provider no debe errar");
+
+    let peers = match loc {
+        ProviderLocation::Remote(peers) => {
+            assert!(
+                peers.contains(&a_peer),
+                "el fallback DHT debería descubrir a A; encontrados: {:?}, esperado: {}",
+                peers,
+                a_peer
+            );
+            peers
+        }
+        ProviderLocation::Local(s) => {
+            panic!("esperaba Remote (no hay init local), obtuve Local({:?})", s)
+        }
+    };
+
+    // Consumir: abre handshake firmado por libp2p contra A y pinea.
+    let mut remote = consume_remote(&b_net, consumer, None, peers)
+        .await
+        .expect("consume_remote debe abrir handshake firmado contra A");
+
+    remote
+        .ping()
+        .await
+        .expect("ping libp2p sobre la sesión consumer-remoto");
+
+    // Cierre limpio de ambas sesiones.
+    remote.farewell().await.ok();
+    local_provider_client.farewell().await.ok();
+}
+
+/// Si el DHT no anuncia ningún proveedor para el flow pedido, `consume_remote`
+/// no debe colgar ni intentar nada — debe fallar inmediato con
+/// `NoRemoteProviders`. Este test no necesita Net A ni B, sólo un net solo y
+/// una lista vacía de peers.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn consume_remote_falla_inmediato_si_no_hay_peers() {
+    let net = BrahmanNet::new().unwrap();
+    let consumer = build_consumer_card("test.consumer_solo", "flow-fantasma", "json");
+
+    let err = consume_remote(&net, consumer, None, vec![])
+        .await
+        .expect_err("vector vacío debe traducirse a NoRemoteProviders");
+
+    match err {
+        ConsumerError::NoRemoteProviders { flow, type_ref } => {
+            assert_eq!(flow, "flow-fantasma");
+            assert_eq!(type_ref, "json");
+        }
+        otro => panic!("esperaba NoRemoteProviders, obtuve {otro:?}"),
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml
new file mode 100644
index 0000000..a49c7d5
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "chasqui-broker-explorer-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Probe Llimphi del broker brahman: conecta cada N segundos vía await_provider_blocking con un Card observer agnóstico, reporta 3 estados (down / up sin provider / up con provider) y trackea la diff de matches entre ticks. Reemplazo del `chasqui-broker-explorer` GPUI."
+
+[dependencies]
+chasqui-broker = { path = "../chasqui-broker" }
+card-core = { workspace = true }
+card-handshake = { path = "../card-handshake" }
+card-sidecar = { path = "../card-sidecar" }
+ulid = { workspace = true }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-app-header = { workspace = true }
+llimphi-widget-banner = { workspace = true }
+llimphi-widget-stat-card = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+
+[[bin]]
+name = "chasqui-broker-explorer-llimphi"
+path = "src/main.rs"
diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md
new file mode 100644
index 0000000..c47770e
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md
@@ -0,0 +1,16 @@
+# chasqui-broker-explorer-llimphi
+
+> UI Llimphi: topics + suscriptores activos del broker de [chasqui](../README.md).
+
+Lista los topics actuales, cuántos suscriptores tiene cada uno, throughput por topic, persistencia activa o no. Útil para diagnosticar el sistema.
+
+## Uso
+
+```sh
+cargo run --release -p chasqui-broker-explorer-llimphi
+```
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
+- [`llimphi-ui`](../../llimphi/) + widgets `list`, `tabs`
diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md
new file mode 100644
index 0000000..dd0a19b
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md
@@ -0,0 +1,16 @@
+# chasqui-broker-explorer-llimphi
+
+> Llimphi UI: topics + active subscribers of [chasqui](../README.md)'s broker.
+
+Lists current topics, how many subscribers each has, throughput per topic, persistence active or not. Useful for diagnosing the system.
+
+## Usage
+
+```sh
+cargo run --release -p chasqui-broker-explorer-llimphi
+```
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
+- [`llimphi-ui`](../../llimphi/) + widgets `list`, `tabs`
diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs
new file mode 100644
index 0000000..0229101
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs
@@ -0,0 +1,868 @@
+//! `chasqui-broker-explorer-llimphi` — probe Llimphi del broker
+//! brahman.
+//!
+//! Cada [`POLL_INTERVAL`] arma un Card observer agnóstico y lo manda
+//! al broker via `card_sidecar::await_provider_blocking` (que
+//! internamente abre tokio runtime + Unix socket + handshake).
+//! Reporta 3 estados:
+//!
+//! - **Down**: connect failed (broker no escucha en el socket).
+//! - **Up sin provider**: connect OK, pero el broker no encontró
+//!   productor para el flow probado dentro del timeout.
+//! - **Up con provider**: connect OK + el broker matcheó algo →
+//!   muestra el `producer_service_socket` recibido.
+//!
+//! Configuración via env:
+//! - `BRAHMAN_INIT_SOCKET` — path del socket del broker (default
+//!   resuelto por `card_handshake::transport`).
+//! - `BRAHMAN_BROKER_PROBE_FLOW` — nombre del flow probe (default
+//!   `broker-health`).
+//! - `BRAHMAN_BROKER_PROBE_TYPE` — type name del flow probe
+//!   (default `ping`).
+//!
+//! Usá un type name probable (ej. `monad-list:json`,
+//! `event-log:tail`) para detectar productores específicos del
+//! ecosistema.
+
+#![forbid(unsafe_code)]
+
+use std::collections::{HashSet, VecDeque};
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use card_handshake::messages::SessionList;
+use card_handshake::transport;
+use card_sidecar::{
+    await_provider_blocking, build_consumer_card, list_matches_blocking, list_sessions_blocking,
+    ConsumerError,
+};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, Dimension, FlexDirection, Size, Style},
+    Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_widget_app_header::{app_header, AppHeaderPalette};
+use llimphi_widget_banner::{banner_view, BannerKind};
+use llimphi_widget_context_menu::{
+    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+    menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+    DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
+use ulid::Ulid;
+
+use app_bus::{AppMenu, Menu, MenuItem};
+
+const POLL_INTERVAL: Duration = Duration::from_secs(5);
+const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
+
+/// Cap del buffer del timeline. Mantenemos las últimas N entries —
+/// más viejo se descarta. 50 cubre ~4 minutos de actividad densa.
+const TIMELINE_CAP: usize = 50;
+
+#[derive(Clone, Debug)]
+enum ProbeState {
+    Pending,
+    Down { reason: String },
+    UpNoProvider { flow: String },
+    UpWithProvider { flow: String, producer_socket: PathBuf },
+}
+
+#[derive(Clone, Debug)]
+struct TimelineEntry {
+    at: std::time::SystemTime,
+    kind: card_handshake::messages::MatchEventKind,
+    consumer_label: String,
+    consumer_flow: String,
+    producer_label: String,
+    producer_flow: String,
+    via: chasqui_broker::MatchStrategy,
+    pinned: bool,
+}
+
+type MatchKey = (Ulid, String, Ulid, String);
+
+struct Model {
+    theme: Theme,
+    socket_path: PathBuf,
+    flow: String,
+    type_name: String,
+    state: ProbeState,
+    last_probe_ms: u64,
+    sessions: Option<SessionList>,
+    last_match_keys: HashSet<MatchKey>,
+    timeline: VecDeque<TimelineEntry>,
+    /// Barra de menú principal: índice del menú raíz abierto (`None`
+    /// cerrado).
+    menu_open: Option<usize>,
+    /// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna).
+    menu_active: usize,
+    /// Animación de aparición del dropdown.
+    menu_anim: Tween<f32>,
+    /// Entry del timeline seleccionada (índice en `timeline`). `None`
+    /// si ninguna. La selección sólo habilita el menú contextual; el
+    /// probe es de sólo lectura.
+    selected: Option<usize>,
+    /// Menú contextual sobre una entry: `(idx, x, y)` ancla en ventana.
+    /// `None` cerrado.
+    context_menu: Option<(usize, f32, f32)>,
+}
+
+#[derive(Clone)]
+enum Msg {
+    Tick,
+    ProbeResult {
+        state: ProbeState,
+        sessions: Option<SessionList>,
+        matches: Option<card_handshake::messages::MatchList>,
+        elapsed_ms: u64,
+    },
+    /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
+    MenuOpen(Option<usize>),
+    /// Comando elegido en el menú principal — se traduce al `Msg` real.
+    MenuCommand(String),
+    /// Navega la fila activa del dropdown (+1/-1).
+    MenuNav(i32),
+    /// Ejecuta el comando de la fila activa (Enter).
+    MenuActivate,
+    /// No-op: sólo fuerza re-render durante la animación del dropdown.
+    MenuTick,
+    /// Cierra cualquier menú abierto (click-fuera / Esc).
+    CloseMenus,
+    /// Cicla el tema entre presets.
+    CycleTheme,
+    /// Selecciona una entry del timeline por índice (resalta).
+    SelectEntry(usize),
+    /// Right-click en la raíz → abre el menú contextual anclado en
+    /// `(x, y)` de ventana sobre la entry seleccionada. Sin selección
+    /// es no-op.
+    ContextMenuOpen(f32, f32),
+    /// Limpia el timeline acumulado.
+    ClearTimeline,
+}
+
+struct Explorer;
+
+impl App for Explorer {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "Brahman Broker — Probe"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (720, 480)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        let flow = std::env::var("BRAHMAN_BROKER_PROBE_FLOW")
+            .unwrap_or_else(|_| "broker-health".to_string());
+        let type_name = std::env::var("BRAHMAN_BROKER_PROBE_TYPE")
+            .unwrap_or_else(|_| "ping".to_string());
+
+        handle.dispatch(Msg::Tick);
+        handle.spawn_periodic(POLL_INTERVAL, || Msg::Tick);
+
+        Model {
+            theme: Theme::dark(),
+            socket_path: transport::default_socket_path(),
+            flow,
+            type_name,
+            state: ProbeState::Pending,
+            last_probe_ms: 0,
+            sessions: None,
+            last_match_keys: HashSet::new(),
+            timeline: VecDeque::new(),
+            menu_open: None,
+            menu_active: usize::MAX,
+            menu_anim: Tween::idle(1.0),
+            selected: None,
+            context_menu: None,
+        }
+    }
+
+    fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
+        if event.state != KeyState::Pressed {
+            return None;
+        }
+        if let Some(mi) = model.menu_open {
+            let n = app_menu().menus.len().max(1);
+            return match &event.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+                Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+                Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+                Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+                Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+                Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+                _ => None,
+            };
+        }
+        None
+    }
+
+    fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        let mut m = model;
+        match msg {
+            Msg::Tick => {
+                let flow = m.flow.clone();
+                let type_name = m.type_name.clone();
+                handle.spawn(move || run_probe(flow, type_name));
+            }
+            Msg::ProbeResult { state, sessions, matches, elapsed_ms } => {
+                m.state = state;
+                m.sessions = sessions;
+                if let Some(list) = matches {
+                    m.diff_matches_into_timeline(&list);
+                }
+                m.last_probe_ms = elapsed_ms;
+                // Si la selección quedó fuera de rango tras el refresh
+                // del timeline, la descartamos.
+                if m.selected.map(|i| i >= m.timeline.len()).unwrap_or(false) {
+                    m.selected = None;
+                    m.context_menu = None;
+                }
+            }
+            Msg::MenuOpen(which) => {
+                m.menu_open = which;
+                // Abrir un menú raíz cierra cualquier contextual.
+                m.context_menu = None;
+                m.menu_active = usize::MAX;
+                if which.is_some() {
+                    m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+                    animate(handle, motion::FAST, || Msg::MenuTick);
+                }
+            }
+            Msg::MenuNav(dir) => {
+                if let Some(mi) = m.menu_open {
+                    let menu = app_menu();
+                    m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
+                }
+            }
+            Msg::MenuActivate => {
+                if let Some(mi) = m.menu_open {
+                    let menu = app_menu();
+                    if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
+                        m.menu_open = None;
+                        return handle_menu_command(m, &cmd, handle);
+                    }
+                }
+            }
+            Msg::MenuTick => {}
+            Msg::CloseMenus => {
+                m.menu_open = None;
+                m.menu_active = usize::MAX;
+                m.context_menu = None;
+            }
+            Msg::MenuCommand(cmd) => {
+                m.menu_open = None;
+                m.menu_active = usize::MAX;
+                return handle_menu_command(m, &cmd, handle);
+            }
+            Msg::CycleTheme => {
+                m.theme = Theme::next_after(m.theme.name);
+            }
+            Msg::SelectEntry(i) => {
+                m.selected = Some(i);
+                m.context_menu = None;
+            }
+            Msg::ContextMenuOpen(x, y) => {
+                // Sólo si hay una entry seleccionada válida.
+                if let Some(i) = m.selected.filter(|i| *i < m.timeline.len()) {
+                    m.menu_open = None;
+                    m.context_menu = Some((i, x, y));
+                }
+            }
+            Msg::ClearTimeline => {
+                m.timeline.clear();
+                m.selected = None;
+                m.context_menu = None;
+            }
+        }
+        m
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let theme = &model.theme;
+        let menu = app_menu();
+        let menubar = menubar_view(&menubar_spec(&menu, model, theme));
+        let header_palette = AppHeaderPalette::from_theme(theme);
+        let stat_palette = StatCardPalette::from_theme(theme);
+
+        // Acentos por estado.
+        let accent_up = Color::from_rgba8(0xa3, 0xbe, 0x8c, 0xff);
+        let accent_partial = Color::from_rgba8(0xeb, 0xcb, 0x8b, 0xff);
+        let accent_down = Color::from_rgba8(0xbf, 0x61, 0x6a, 0xff);
+        let accent_pending = Color::from_rgba8(0x6a, 0x72, 0x80, 0xff);
+
+        let header_text = format!(
+            "Probe: {}  ·  flow: {}/{}  ·  reload {} ms",
+            model.socket_path.display(),
+            model.flow,
+            model.type_name,
+            model.last_probe_ms,
+        );
+
+        let header = app_header::<Msg>(header_text, vec![], &header_palette);
+
+        let mut body_children: Vec<View<Msg>> = Vec::new();
+
+        // Banner permanente con el estado actual.
+        match &model.state {
+            ProbeState::Pending => {}
+            ProbeState::Down { reason } => body_children.push(banner_view::<Msg>(
+                BannerKind::Error,
+                format!("Broker DOWN — {reason}"),
+            )),
+            ProbeState::UpNoProvider { .. } => body_children.push(banner_view::<Msg>(
+                BannerKind::Warning,
+                "Broker UP, sin provider para el flow".to_string(),
+            )),
+            ProbeState::UpWithProvider { .. } => body_children.push(banner_view::<Msg>(
+                BannerKind::Success,
+                "Broker UP, provider matcheado".to_string(),
+            )),
+        }
+
+        // State card.
+        let (state_accent, state_value, state_descr) = state_card_params(
+            &model.state,
+            accent_up,
+            accent_partial,
+            accent_down,
+            accent_pending,
+        );
+        body_children.push(stat_card_view::<Msg>(
+            "Estado",
+            state_value,
+            &state_descr,
+            state_accent,
+            &[],
+            &stat_palette,
+        ));
+
+        // Sessions card.
+        let sessions_items: Vec<String> = model
+            .sessions
+            .as_ref()
+            .map(|list| {
+                let mut entries: Vec<_> = list.entries.iter().collect();
+                entries.sort_by_key(|e| e.session);
+                entries
+                    .iter()
+                    .map(|e| {
+                        format!(
+                            "{}  ·  in:[{}]  out:[{}]{}",
+                            e.label,
+                            e.inputs.join(","),
+                            e.outputs.join(","),
+                            if e.conscious { "  (wit)" } else { "" }
+                        )
+                    })
+                    .collect()
+            })
+            .unwrap_or_default();
+        let sessions_count_value = model
+            .sessions
+            .as_ref()
+            .map(|l| l.entries.len().to_string())
+            .unwrap_or_else(|| "—".into());
+        let sessions_descr = match &model.sessions {
+            None => "lista no disponible (broker DOWN o pendiente)".to_string(),
+            Some(l) if l.entries.is_empty() => {
+                "sin sesiones registradas en el broker".into()
+            }
+            Some(_) => "labels visibles + flows in/out · (wit) = consciente".into(),
+        };
+        body_children.push(stat_card_view::<Msg>(
+            "Sesiones activas",
+            sessions_count_value,
+            &sessions_descr,
+            accent_up,
+            &sessions_items,
+            &stat_palette,
+        ));
+
+        // Timeline card de cabecera (contador + ayuda); las entries
+        // van debajo como filas seleccionables (click selecciona,
+        // right-click en la raíz abre el contextual).
+        let timeline_value = model.timeline.len().to_string();
+        let timeline_descr = if model.timeline.is_empty() {
+            "esperando primer match…".to_string()
+        } else {
+            "click selecciona · right-click = menú · cap 50 entries".to_string()
+        };
+        body_children.push(stat_card_view::<Msg>(
+            "Timeline de matches",
+            timeline_value,
+            &timeline_descr,
+            accent_partial,
+            &[],
+            &stat_palette,
+        ));
+
+        for (i, e) in model.timeline.iter().take(20).enumerate() {
+            let selected = model.selected == Some(i);
+            let mut row = View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: length(18.0_f32),
+                },
+                padding: Rect {
+                    left: length(8.0_f32),
+                    right: length(8.0_f32),
+                    top: length(0.0_f32),
+                    bottom: length(0.0_f32),
+                },
+                ..Default::default()
+            })
+            .text_aligned(
+                format_timeline_entry(e),
+                11.0,
+                theme.fg_text,
+                Alignment::Start,
+            )
+            .on_click(Msg::SelectEntry(i));
+            if selected {
+                row = row.fill(theme.bg_selected);
+            }
+            body_children.push(row);
+        }
+
+        let body = View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: Dimension::auto(),
+            },
+            flex_grow: 1.0,
+            padding: Rect {
+                left: length(16.0_f32),
+                right: length(16.0_f32),
+                top: length(12.0_f32),
+                bottom: length(16.0_f32),
+            },
+            gap: Size {
+                width: length(0.0_f32),
+                height: length(8.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        .children(body_children);
+
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        // Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
+        // menú contextual sobre la entry seleccionada.
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+        .children(vec![menubar, header, body])
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        // El menú contextual de la entry tiene prioridad si está abierto.
+        if let Some((idx, x, y)) = model.context_menu {
+            let label = model
+                .timeline
+                .get(idx)
+                .map(format_timeline_entry)
+                .unwrap_or_else(|| "Entry".to_string());
+            let viewport = viewport_of(model);
+            // Acciones reales del probe (sólo lectura): refrescar el
+            // probe y limpiar el timeline. No inventamos edición.
+            let items = vec![
+                ContextMenuItem::action("Refrescar probe"),
+                ContextMenuItem::action("Limpiar timeline"),
+            ];
+            let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+                Arc::new(move |i: usize| match i {
+                    0 => Msg::Tick,
+                    _ => Msg::ClearTimeline,
+                });
+            return Some(context_menu_view(ContextMenuSpec {
+                anchor: (x, y),
+                viewport,
+                header: Some(label),
+                items,
+                active: usize::MAX,
+                on_pick,
+                on_dismiss: Msg::CloseMenus,
+                palette: ContextMenuPalette::from_theme(&model.theme),
+            }));
+        }
+        // Si no, el dropdown del menú principal.
+        let menu = app_menu();
+        menubar_overlay_animated(
+            &menubar_spec(&menu, model, &model.theme),
+            model.menu_active,
+            model.menu_anim.value(),
+        )
+    }
+}
+
+/// Viewport para clampear overlays: el probe no trackea el tamaño de
+/// ventana, así que usamos `initial_size()`.
+fn viewport_of(_model: &Model) -> (f32, f32) {
+    let (w, h) = Explorer::initial_size();
+    (w as f32, h as f32)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(
+    menu: &'a AppMenu,
+    model: &Model,
+    theme: &'a Theme,
+) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: viewport_of(model),
+        height: MENU_H,
+        on_open: Arc::new(Msg::MenuOpen),
+        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+/// El menú principal del probe. Archivo / Ver / Ayuda — sólo comandos
+/// que mapean a acciones reales (refrescar probe, limpiar timeline,
+/// reconectar, tema). Sin "Editar": el probe no tiene campos de texto
+/// editables.
+fn app_menu() -> AppMenu {
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Refrescar probe", "file.refresh").shortcut("Ctrl+R"))
+                .item(MenuItem::new("Limpiar timeline", "file.clear"))
+                .item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
+        )
+        .menu(
+            Menu::new("Ver")
+                .item(MenuItem::new("Reconectar", "view.reconnect"))
+                .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+        )
+        .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+/// Traduce un command id del menú principal al `Msg`/efecto real.
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+    match cmd {
+        // Reconectar == re-disparar el probe; el ProbeResult refresca
+        // estado/sesiones/matches.
+        "file.refresh" | "view.reconnect" => {
+            handle.dispatch(Msg::Tick);
+            model
+        }
+        "file.clear" => {
+            handle.dispatch(Msg::ClearTimeline);
+            model
+        }
+        "file.quit" => std::process::exit(0),
+        "view.theme" => {
+            handle.dispatch(Msg::CycleTheme);
+            model
+        }
+        // "help.about" y desconocidos: no-op (sin diálogo todavía).
+        _ => model,
+    }
+}
+
+impl Model {
+    fn diff_matches_into_timeline(
+        &mut self,
+        list: &card_handshake::messages::MatchList,
+    ) {
+        let (new_entries, new_keys) = diff_matches(&self.last_match_keys, list);
+        for entry in new_entries {
+            self.push_timeline(entry);
+        }
+        self.last_match_keys = new_keys;
+    }
+
+    fn push_timeline(&mut self, entry: TimelineEntry) {
+        self.timeline.push_front(entry);
+        while self.timeline.len() > TIMELINE_CAP {
+            self.timeline.pop_back();
+        }
+    }
+}
+
+fn state_card_params(
+    state: &ProbeState,
+    accent_up: Color,
+    accent_partial: Color,
+    accent_down: Color,
+    accent_pending: Color,
+) -> (Color, String, String) {
+    match state {
+        ProbeState::Pending => (
+            accent_pending,
+            "PENDING".into(),
+            "esperando primer probe…".into(),
+        ),
+        ProbeState::Down { reason } => (
+            accent_down,
+            "DOWN".into(),
+            format!("connect failed: {reason}"),
+        ),
+        ProbeState::UpNoProvider { flow } => (
+            accent_partial,
+            "UP / NO PROVIDER".into(),
+            format!("broker reachable; sin productor para flow `{flow}`"),
+        ),
+        ProbeState::UpWithProvider { flow, producer_socket } => (
+            accent_up,
+            "UP / PROVIDER".into(),
+            format!(
+                "flow `{flow}` matcheado en producer socket: {}",
+                producer_socket.display()
+            ),
+        ),
+    }
+}
+
+fn run_probe(flow: String, type_name: String) -> Msg {
+    let started = Instant::now();
+    let card = build_consumer_card("brahman-broker-explorer-llimphi", flow.clone(), type_name);
+    let result = await_provider_blocking(card, PROBE_TIMEOUT);
+
+    let new_state = match result {
+        Ok(socket) => ProbeState::UpWithProvider {
+            flow: flow.clone(),
+            producer_socket: socket,
+        },
+        Err(ConsumerError::NoProvider { .. }) => ProbeState::UpNoProvider { flow: flow.clone() },
+        Err(e) => ProbeState::Down {
+            reason: e.to_string(),
+        },
+    };
+
+    let (sessions, matches) = match &new_state {
+        ProbeState::Down { .. } | ProbeState::Pending => (None, None),
+        _ => (
+            list_sessions_blocking("brahman-broker-explorer-llimphi").ok(),
+            list_matches_blocking("brahman-broker-explorer-llimphi").ok(),
+        ),
+    };
+
+    Msg::ProbeResult {
+        state: new_state,
+        sessions,
+        matches,
+        elapsed_ms: started.elapsed().as_millis() as u64,
+    }
+}
+
+/// Diff puro entre snapshots de matches. Devuelve la lista de
+/// entries nuevas (Available + Lost) en orden Available-primero, y
+/// el set actualizado de keys.
+fn diff_matches(
+    last_keys: &HashSet<MatchKey>,
+    list: &card_handshake::messages::MatchList,
+) -> (Vec<TimelineEntry>, HashSet<MatchKey>) {
+    use card_handshake::messages::MatchEventKind;
+    let now = std::time::SystemTime::now();
+    let current_keys: HashSet<MatchKey> = list
+        .matches
+        .iter()
+        .map(|m| {
+            (
+                m.consumer.session,
+                m.consumer.flow_name.clone(),
+                m.producer.session,
+                m.producer.flow_name.clone(),
+            )
+        })
+        .collect();
+
+    let mut entries = Vec::new();
+    for m in &list.matches {
+        let key = (
+            m.consumer.session,
+            m.consumer.flow_name.clone(),
+            m.producer.session,
+            m.producer.flow_name.clone(),
+        );
+        if !last_keys.contains(&key) {
+            entries.push(TimelineEntry {
+                at: now,
+                kind: MatchEventKind::Available,
+                consumer_label: m.consumer_label.clone(),
+                consumer_flow: m.consumer.flow_name.clone(),
+                producer_label: m.producer_label.clone(),
+                producer_flow: m.producer.flow_name.clone(),
+                via: m.via,
+                pinned: m.pinned,
+            });
+        }
+    }
+    for key in last_keys.iter() {
+        if !current_keys.contains(key) {
+            entries.push(TimelineEntry {
+                at: now,
+                kind: MatchEventKind::Lost,
+                consumer_label: String::new(),
+                consumer_flow: key.1.clone(),
+                producer_label: String::new(),
+                producer_flow: key.3.clone(),
+                via: chasqui_broker::MatchStrategy::Exact,
+                pinned: false,
+            });
+        }
+    }
+    (entries, current_keys)
+}
+
+fn format_timeline_entry(e: &TimelineEntry) -> String {
+    use card_handshake::messages::MatchEventKind;
+    let secs_today = e
+        .at
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs() % 86_400)
+        .unwrap_or(0);
+    let h = secs_today / 3600;
+    let m = (secs_today % 3600) / 60;
+    let s = secs_today % 60;
+    let kind = match e.kind {
+        MatchEventKind::Available => "+",
+        MatchEventKind::Lost => "-",
+    };
+    let pinned = if e.pinned { " (pinned)" } else { "" };
+    match e.kind {
+        MatchEventKind::Available => format!(
+            "{:02}:{:02}:{:02} {} {}.{} ← {}.{} [{:?}]{}",
+            h,
+            m,
+            s,
+            kind,
+            e.consumer_label,
+            e.consumer_flow,
+            e.producer_label,
+            e.producer_flow,
+            e.via,
+            pinned,
+        ),
+        MatchEventKind::Lost => format!(
+            "{:02}:{:02}:{:02} {} ?.{} ← ?.{} (lost)",
+            h, m, s, kind, e.consumer_flow, e.producer_flow,
+        ),
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Explorer>();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn pending_is_default_state_at_boot() {
+        let s = ProbeState::Pending;
+        assert!(matches!(s, ProbeState::Pending));
+    }
+
+    #[test]
+    fn poll_and_probe_constants_are_sane() {
+        assert!(PROBE_TIMEOUT < POLL_INTERVAL);
+        assert!(POLL_INTERVAL >= Duration::from_secs(2));
+    }
+
+    fn synth_match(
+        consumer_label: &str,
+        consumer_flow: &str,
+        producer_label: &str,
+        producer_flow: &str,
+    ) -> chasqui_broker::Match {
+        use card_core::TypeRef;
+        use chasqui_broker::{Endpoint, Match, MatchStrategy};
+        Match {
+            consumer: Endpoint {
+                session: Ulid::new(),
+                flow_name: consumer_flow.into(),
+            },
+            consumer_label: consumer_label.into(),
+            producer: Endpoint {
+                session: Ulid::new(),
+                flow_name: producer_flow.into(),
+            },
+            producer_label: producer_label.into(),
+            ty: TypeRef::Primitive { name: "json".into() },
+            via: MatchStrategy::Exact,
+            pinned: false,
+        }
+    }
+
+    #[test]
+    fn diff_matches_first_snapshot_marks_everything_available() {
+        use card_handshake::messages::{MatchEventKind, MatchList};
+        let list = MatchList {
+            matches: vec![
+                synth_match("a", "x", "b", "x"),
+                synth_match("c", "y", "d", "y"),
+            ],
+        };
+        let last = HashSet::new();
+        let (entries, keys) = diff_matches(&last, &list);
+        assert_eq!(entries.len(), 2);
+        assert!(entries
+            .iter()
+            .all(|e| matches!(e.kind, MatchEventKind::Available)));
+        assert_eq!(keys.len(), 2);
+    }
+
+    #[test]
+    fn diff_matches_emits_lost_when_match_disappears() {
+        use card_handshake::messages::{MatchEventKind, MatchList};
+        let m = synth_match("a", "x", "b", "x");
+        let prev_key = (
+            m.consumer.session,
+            m.consumer.flow_name.clone(),
+            m.producer.session,
+            m.producer.flow_name.clone(),
+        );
+        let last: HashSet<_> = std::iter::once(prev_key).collect();
+        let list = MatchList { matches: vec![] };
+        let (entries, keys) = diff_matches(&last, &list);
+        assert_eq!(entries.len(), 1);
+        assert!(matches!(entries[0].kind, MatchEventKind::Lost));
+        assert_eq!(entries[0].consumer_flow, "x");
+        assert_eq!(entries[0].producer_flow, "x");
+        assert!(keys.is_empty());
+    }
+
+    #[test]
+    fn diff_matches_no_change_emits_nothing() {
+        use card_handshake::messages::MatchList;
+        let m = synth_match("a", "x", "b", "x");
+        let key = (
+            m.consumer.session,
+            m.consumer.flow_name.clone(),
+            m.producer.session,
+            m.producer.flow_name.clone(),
+        );
+        let last: HashSet<_> = std::iter::once(key.clone()).collect();
+        let list = MatchList {
+            matches: vec![m.clone()],
+        };
+        let (entries, keys) = diff_matches(&last, &list);
+        assert!(entries.is_empty(), "match unchanged → no events");
+        assert_eq!(keys.len(), 1);
+        assert!(keys.contains(&key));
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-broker/Cargo.toml b/02_ruway/chasqui/chasqui-broker/Cargo.toml
new file mode 100644
index 0000000..05eac74
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "chasqui-broker"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — broker de tipos: empareja productores↔consumidores por TypeRef con matching híbrido (exact + structural) y prioridad."
+
+[dependencies]
+card-core = { workspace = true }
+serde = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/02_ruway/chasqui/chasqui-broker/LEEME.md b/02_ruway/chasqui/chasqui-broker/LEEME.md
new file mode 100644
index 0000000..1d77c74
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker/LEEME.md
@@ -0,0 +1,16 @@
+# chasqui-broker
+
+> Binario del broker de [chasqui](../README.md).
+
+Loop async sobre `tokio` que rutea mensajes entre topics. TCP/Unix configurables. Persistencia opt-in por topic en `$XDG_DATA_HOME/chasqui/`.
+
+## Uso
+
+```sh
+cargo run --release -p chasqui-broker -- --listen 127.0.0.1:7711
+```
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
+- `tokio`, `clap`
diff --git a/02_ruway/chasqui/chasqui-broker/README.md b/02_ruway/chasqui/chasqui-broker/README.md
new file mode 100644
index 0000000..adf8219
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker/README.md
@@ -0,0 +1,16 @@
+# chasqui-broker
+
+> Broker binary of [chasqui](../README.md).
+
+Async loop on `tokio` that routes messages between topics. Configurable TCP/Unix. Opt-in persistence per topic at `$XDG_DATA_HOME/chasqui/`.
+
+## Usage
+
+```sh
+cargo run --release -p chasqui-broker -- --listen 127.0.0.1:7711
+```
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
+- `tokio`, `clap`
diff --git a/02_ruway/chasqui/chasqui-broker/src/lib.rs b/02_ruway/chasqui/chasqui-broker/src/lib.rs
new file mode 100644
index 0000000..b096637
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-broker/src/lib.rs
@@ -0,0 +1,995 @@
+//! `brahman-broker` — empareja productores y consumidores por tipo de flujo.
+//!
+//! El broker indexa [`card_core::Card`]s registradas por `SessionId` y,
+//! para cada `flow.input` de un consumidor, busca el `flow.output`
+//! compatible de mejor calidad entre los demás. Tres ejes:
+//!
+//! 1. **Estrategia de matching** ([`MatchStrategy`]):
+//!    - `Exact`: igualdad estricta de [`card_core::TypeRef`].
+//!    - `Structural`: misma forma (mismo `package` + `name` para Wit;
+//!      ignora `interface`).
+//!    - `ExactThenStructural`: prefiere exact; cae en structural si no hay.
+//!
+//! 2. **Override `pin_to`**: si el consumidor declara `pin_to = "label"`,
+//!    el broker prefiere productores cuya Card tenga ese `label` (siempre
+//!    que el tipo siga matcheando). Si la pista no resuelve, cae en
+//!    matching por tipo normal.
+//!
+//! 3. **Prioridad**: empate de tipo se resuelve por
+//!    [`card_core::Priority`] del productor (mayor gana). Empate de
+//!    prioridad se resuelve lexicográficamente por `label` (estable y
+//!    determinista).
+//!
+//! El broker es **stateless w.r.t. routes**: cada `find_producer_for` o
+//! `all_matches` se calcula bajo demanda. La única persistencia es el
+//! índice de Cards registradas. Esto permite re-evaluar matches cuando
+//! cambia el set sin invalidar caches.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use card_core::{
+    Card, CardKind, CardReference, ContextBias, DataFacet, Flow, Lifecycle, Priority, TypeRef,
+    WitInterface,
+};
+use serde::{Deserialize, Serialize};
+use ulid::Ulid;
+
+/// Identificador de sesión emitido por el handshake. Idéntico al usado por
+/// `brahman-handshake` (no es un re-export para evitar la dependencia).
+pub type SessionId = Ulid;
+
+/// Estrategia de matching de tipos.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum MatchStrategy {
+    /// Igualdad estricta de `TypeRef`.
+    Exact,
+    /// Misma forma: para `Wit`, mismo `package` + `name`; para
+    /// `Primitive`, mismo `name`.
+    Structural,
+    /// Híbrido: intenta `Exact` primero; si no matchea, `Structural`.
+    /// Reporta cuál estrategia ganó en [`Match::via`].
+    #[default]
+    ExactThenStructural,
+}
+
+/// Configuración del broker.
+#[derive(Debug, Clone, Default)]
+pub struct BrokerConfig {
+    pub strategy: MatchStrategy,
+    /// Contexto operativo activo. Si una Card declara un
+    /// `priority_contexts.<this>`, ese bias se aplica durante el match.
+    /// `None` = sin biases per-contexto, sólo se usa lo estático.
+    pub current_context: Option<String>,
+}
+
+/// Vista mínima de una Card que el broker necesita.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BrokeredCard {
+    pub session: SessionId,
+    pub label: String,
+    pub lifecycle: Lifecycle,
+    pub priority: Priority,
+    pub inputs: Vec<Flow>,
+    pub outputs: Vec<Flow>,
+    /// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico.
+    pub wit: Option<WitInterface>,
+    /// Biases per-contexto, propagados desde `Card.priority_contexts`.
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub priority_contexts: BTreeMap<String, ContextBias>,
+    /// Naturaleza de la entidad. Diferencia procesos (Ente) de
+    /// agrupaciones de datos (Data — p. ej. Mónadas Nouser).
+    #[serde(default)]
+    pub kind: CardKind,
+    /// Faceta de datos cuando `kind != Ente`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub data: Option<DataFacet>,
+    /// Socket de servicio (data plane) si lo declara la Card.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub service_socket: Option<PathBuf>,
+    /// Referencias a otras Cards (relaciones declaradas por esta Card).
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub references: Vec<CardReference>,
+}
+
+impl BrokeredCard {
+    fn from_card(session: SessionId, card: &Card, wit: Option<WitInterface>) -> Self {
+        Self {
+            session,
+            label: card.label.clone(),
+            lifecycle: card.lifecycle,
+            priority: card.priority,
+            inputs: card.flow.input.clone(),
+            outputs: card.flow.output.clone(),
+            wit,
+            priority_contexts: card.priority_contexts.clone(),
+            kind: card.kind,
+            data: card.data.clone(),
+            service_socket: card.service_socket.clone(),
+            references: card.references.clone(),
+        }
+    }
+}
+
+/// Punto extremo de un flujo: qué sesión + nombre del flow dentro de su Card.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct Endpoint {
+    pub session: SessionId,
+    pub flow_name: String,
+}
+
+/// Match concreto entre un consumidor y un productor.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Match {
+    pub consumer: Endpoint,
+    pub consumer_label: String,
+    pub producer: Endpoint,
+    pub producer_label: String,
+    /// Tipo del flow (lado consumidor — lado productor coincide en
+    /// estrategia Exact, puede diferir en `interface` en Structural).
+    pub ty: TypeRef,
+    /// Estrategia que efectivamente matcheó.
+    pub via: MatchStrategy,
+    /// `true` si el match fue resuelto por `pin_to` y no por type-search.
+    pub pinned: bool,
+}
+
+// =====================================================================
+// Broker
+// =====================================================================
+
+/// El broker. Registra Cards por SessionId, computa matches bajo demanda.
+#[derive(Debug, Clone, Default)]
+pub struct Broker {
+    cards: BTreeMap<SessionId, BrokeredCard>,
+    config: BrokerConfig,
+}
+
+impl Broker {
+    pub fn new(config: BrokerConfig) -> Self {
+        Self {
+            cards: BTreeMap::new(),
+            config,
+        }
+    }
+
+    /// Registra una Card con su WIT opcional. Devuelve `Some(prev)` si
+    /// reemplazó una existente. Pasar `None` en `wit` indica módulo
+    /// agnóstico (sin contrato WIT extraído).
+    pub fn register(
+        &mut self,
+        session: SessionId,
+        card: &Card,
+        wit: Option<WitInterface>,
+    ) -> Option<BrokeredCard> {
+        self.cards
+            .insert(session, BrokeredCard::from_card(session, card, wit))
+    }
+
+    /// Quita una Card por sesión.
+    pub fn unregister(&mut self, session: SessionId) -> Option<BrokeredCard> {
+        self.cards.remove(&session)
+    }
+
+    /// Cardinalidad del registro.
+    pub fn len(&self) -> usize {
+        self.cards.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.cards.is_empty()
+    }
+
+    /// Iterador sobre las sesiones registradas.
+    pub fn sessions(&self) -> impl Iterator<Item = SessionId> + '_ {
+        self.cards.keys().copied()
+    }
+
+    /// Iterador sobre las Cards registradas (vista compartida).
+    pub fn cards(&self) -> impl Iterator<Item = &BrokeredCard> + '_ {
+        self.cards.values()
+    }
+
+    /// Busca el mejor productor para un input específico de un consumidor.
+    ///
+    /// Algoritmo:
+    /// 1. Resuelve el flow input en el consumidor.
+    /// 2. Si tiene `pin_to`, prefiere productores con ese `label` que
+    ///    matcheen el tipo (cualquier estrategia configurada).
+    /// 3. Si no hay pin_to o la pista falló, escanea todos los outputs
+    ///    de las otras Cards. Filtra por compatibilidad de tipo.
+    /// 4. Ordena por (priority desc, label asc) y devuelve el primero.
+    pub fn find_producer_for(&self, consumer: SessionId, input_name: &str) -> Option<Match> {
+        let cons = self.cards.get(&consumer)?;
+        let input = cons.inputs.iter().find(|f| f.name == input_name)?;
+
+        // pin_to efectivo: bias del contexto activo (si la Card declara
+        // override consumer-side) > pin_to estático del Flow.
+        let context_pin = self
+            .context_bias(cons)
+            .and_then(|b| b.pin_to.as_deref());
+        let effective_pin = context_pin.or(input.pin_to.as_deref());
+
+        if let Some(pin) = effective_pin {
+            for prod in self.cards.values() {
+                if prod.session == consumer || prod.label != pin {
+                    continue;
+                }
+                for out in &prod.outputs {
+                    if let Some(via) = self.types_match(&input.ty, &out.ty) {
+                        return Some(self.make_match(cons, prod, input, out, via, true));
+                    }
+                }
+            }
+            // Fall through: pin no resuelto, type-search general.
+        }
+
+        let mut candidates: Vec<(&BrokeredCard, &Flow, MatchStrategy)> = Vec::new();
+        for prod in self.cards.values() {
+            if prod.session == consumer {
+                continue;
+            }
+            for out in &prod.outputs {
+                if let Some(via) = self.types_match(&input.ty, &out.ty) {
+                    candidates.push((prod, out, via));
+                }
+            }
+        }
+
+        // Sort por (effective priority desc, label asc). El bias del
+        // contexto puede subir o bajar la priority del productor.
+        candidates.sort_by(|(a, _, _), (b, _, _)| {
+            self.effective_priority(b)
+                .cmp(&self.effective_priority(a))
+                .then_with(|| a.label.cmp(&b.label))
+        });
+
+        let (prod, out, via) = candidates.into_iter().next()?;
+        Some(self.make_match(cons, prod, input, out, via, false))
+    }
+
+    /// Devuelve el `ContextBias` que aplica a este Card en el contexto
+    /// activo (si lo hay).
+    fn context_bias<'a>(&self, card: &'a BrokeredCard) -> Option<&'a ContextBias> {
+        self.config
+            .current_context
+            .as_ref()
+            .and_then(|ctx| card.priority_contexts.get(ctx))
+    }
+
+    /// Priority efectiva del Card como productor, considerando el bias
+    /// del contexto activo. El offset se clampa a `[Low=0, Critical=3]`.
+    fn effective_priority(&self, card: &BrokeredCard) -> i16 {
+        let base = priority_value(card.priority);
+        let offset = self
+            .context_bias(card)
+            .map(|b| b.priority_offset as i16)
+            .unwrap_or(0);
+        (base + offset).clamp(0, 3)
+    }
+
+    /// Calcula todos los matches consumer→producer en el set actual.
+    /// Útil para introspección o para que el Admin emita rutas en lote.
+    pub fn all_matches(&self) -> Vec<Match> {
+        let mut out = Vec::new();
+        for cons in self.cards.values() {
+            for input in &cons.inputs {
+                if let Some(m) = self.find_producer_for(cons.session, &input.name) {
+                    out.push(m);
+                }
+            }
+        }
+        out
+    }
+
+    fn types_match(&self, consumer_ty: &TypeRef, producer_ty: &TypeRef) -> Option<MatchStrategy> {
+        match self.config.strategy {
+            MatchStrategy::Exact => exact_match(consumer_ty, producer_ty).then_some(MatchStrategy::Exact),
+            MatchStrategy::Structural => {
+                structural_match(consumer_ty, producer_ty).then_some(MatchStrategy::Structural)
+            }
+            MatchStrategy::ExactThenStructural => {
+                if exact_match(consumer_ty, producer_ty) {
+                    Some(MatchStrategy::Exact)
+                } else if structural_match(consumer_ty, producer_ty) {
+                    Some(MatchStrategy::Structural)
+                } else {
+                    None
+                }
+            }
+        }
+    }
+
+    fn make_match(
+        &self,
+        cons: &BrokeredCard,
+        prod: &BrokeredCard,
+        input: &Flow,
+        output: &Flow,
+        via: MatchStrategy,
+        pinned: bool,
+    ) -> Match {
+        Match {
+            consumer: Endpoint {
+                session: cons.session,
+                flow_name: input.name.clone(),
+            },
+            consumer_label: cons.label.clone(),
+            producer: Endpoint {
+                session: prod.session,
+                flow_name: output.name.clone(),
+            },
+            producer_label: prod.label.clone(),
+            ty: input.ty.clone(),
+            via,
+            pinned,
+        }
+    }
+}
+
+// =====================================================================
+// Predicados de matching (libres, testeables aislados)
+// =====================================================================
+
+fn priority_value(p: Priority) -> i16 {
+    match p {
+        Priority::Low => 0,
+        Priority::Normal => 1,
+        Priority::High => 2,
+        Priority::Critical => 3,
+    }
+}
+
+fn exact_match(a: &TypeRef, b: &TypeRef) -> bool {
+    a == b
+}
+
+fn structural_match(a: &TypeRef, b: &TypeRef) -> bool {
+    match (a, b) {
+        (TypeRef::Primitive { name: na }, TypeRef::Primitive { name: nb }) => na == nb,
+        (
+            TypeRef::Wit {
+                package: pa, name: na, ..
+            },
+            TypeRef::Wit {
+                package: pb, name: nb, ..
+            },
+        ) => pa == pb && na == nb,
+        _ => false,
+    }
+}
+
+// =====================================================================
+// Tests
+// =====================================================================
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use card_core::{Card, Flows, Payload, Supervision, CARD_SCHEMA_VERSION};
+
+    fn card(label: &str, priority: Priority, flows: Flows) -> Card {
+        Card {
+            schema_version: CARD_SCHEMA_VERSION,
+            id: Ulid::new(),
+            label: label.into(),
+            payload: Payload::Virtual,
+            supervision: Supervision::OneShot,
+            priority,
+            flow: flows,
+            ..Default::default()
+        }
+    }
+
+    fn prim(name: &str) -> TypeRef {
+        TypeRef::Primitive { name: name.into() }
+    }
+
+    fn wit(pkg: &str, iface: Option<&str>, name: &str) -> TypeRef {
+        TypeRef::Wit {
+            package: pkg.into(),
+            interface: iface.map(|s| s.into()),
+            name: name.into(),
+        }
+    }
+
+    fn flow(name: &str, ty: TypeRef, pin: Option<&str>) -> Flow {
+        Flow {
+            name: name.into(),
+            ty,
+            pin_to: pin.map(|s| s.into()),
+        }
+    }
+
+    #[test]
+    fn exact_match_same_typeref() {
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::Exact,
+            current_context: None,
+        });
+        let producer = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("results", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("query", prim("string"), None)],
+                output: vec![],
+            },
+        );
+        let s_prod = Ulid::new();
+        let s_cons = Ulid::new();
+        b.register(s_prod, &producer, None);
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "query").expect("match");
+        assert_eq!(m.producer_label, "dht");
+        assert_eq!(m.via, MatchStrategy::Exact);
+        assert!(!m.pinned);
+    }
+
+    #[test]
+    fn structural_ignores_interface() {
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::Structural,
+            current_context: None,
+        });
+        let producer = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow(
+                    "out",
+                    wit("brahman:dht", Some("v1"), "entity-result"),
+                    None,
+                )],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow(
+                    "in",
+                    wit("brahman:dht", Some("v2"), "entity-result"),
+                    None,
+                )],
+                output: vec![],
+            },
+        );
+        let s_prod = Ulid::new();
+        let s_cons = Ulid::new();
+        b.register(s_prod, &producer, None);
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        assert_eq!(m.via, MatchStrategy::Structural);
+    }
+
+    #[test]
+    fn exact_strategy_rejects_interface_mismatch() {
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::Exact,
+            current_context: None,
+        });
+        let producer = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow(
+                    "out",
+                    wit("brahman:dht", Some("v1"), "entity-result"),
+                    None,
+                )],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow(
+                    "in",
+                    wit("brahman:dht", Some("v2"), "entity-result"),
+                    None,
+                )],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &producer, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        assert!(b.find_producer_for(s_cons, "in").is_none());
+    }
+
+    #[test]
+    fn exact_then_structural_prefers_exact() {
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::ExactThenStructural,
+            current_context: None,
+        });
+        // Productor 1: match estructural (interface diferente)
+        let p_struct = card(
+            "dht-cache",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow(
+                    "out",
+                    wit("brahman:dht", Some("v2"), "entity-result"),
+                    None,
+                )],
+            },
+        );
+        // Productor 2: match exact (interface igual)
+        let p_exact = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow(
+                    "out",
+                    wit("brahman:dht", Some("v1"), "entity-result"),
+                    None,
+                )],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow(
+                    "in",
+                    wit("brahman:dht", Some("v1"), "entity-result"),
+                    None,
+                )],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &p_struct, None);
+        b.register(Ulid::new(), &p_exact, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        // El exact gana incluso si tiene priority igual: por estrategia.
+        assert_eq!(m.producer_label, "dht");
+        assert_eq!(m.via, MatchStrategy::Exact);
+    }
+
+    #[test]
+    fn pin_to_overrides_type_search() {
+        let mut b = Broker::new(BrokerConfig::default());
+        // Dos productores que producen el mismo tipo.
+        let p1 = card(
+            "dht-prod",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let p2 = card(
+            "dht-test",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), Some("dht-test"))],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &p1, None);
+        b.register(Ulid::new(), &p2, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        assert_eq!(m.producer_label, "dht-test");
+        assert!(m.pinned);
+    }
+
+    #[test]
+    fn pin_to_unresolvable_falls_back_to_type_match() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let p = card(
+            "real-dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), Some("nonexistent"))],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &p, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        assert_eq!(m.producer_label, "real-dht");
+        assert!(!m.pinned);
+    }
+
+    #[test]
+    fn priority_breaks_ties() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let p_low = card(
+            "z-dht",
+            Priority::Low,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let p_high = card(
+            "a-dht",
+            Priority::High,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &p_low, None);
+        b.register(Ulid::new(), &p_high, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        assert_eq!(m.producer_label, "a-dht"); // priority High > Low
+    }
+
+    #[test]
+    fn label_alpha_breaks_priority_ties() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let p1 = card(
+            "z-dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let p2 = card(
+            "a-dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![],
+            },
+        );
+        b.register(Ulid::new(), &p1, None);
+        b.register(Ulid::new(), &p2, None);
+        let s_cons = Ulid::new();
+        b.register(s_cons, &consumer, None);
+
+        let m = b.find_producer_for(s_cons, "in").expect("match");
+        assert_eq!(m.producer_label, "a-dht"); // alfabético gana
+    }
+
+    #[test]
+    fn unregister_removes_producer() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let p = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![],
+            },
+        );
+        let s_p = Ulid::new();
+        b.register(s_p, &p, None);
+        let s_c = Ulid::new();
+        b.register(s_c, &consumer, None);
+
+        assert!(b.find_producer_for(s_c, "in").is_some());
+        b.unregister(s_p);
+        assert!(b.find_producer_for(s_c, "in").is_none());
+    }
+
+    #[test]
+    fn no_self_loops() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let same = card(
+            "echo",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let s = Ulid::new();
+        b.register(s, &same, None);
+
+        // Solo una Card registrada — no hay otra que produzca string.
+        assert!(b.find_producer_for(s, "in").is_none());
+    }
+
+    #[test]
+    fn all_matches_lists_pairs() {
+        let mut b = Broker::new(BrokerConfig::default());
+        let dht = card(
+            "dht",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("query", prim("string"), None)],
+                output: vec![flow("results", prim("bytes"), None)],
+            },
+        );
+        let ui = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("data", prim("bytes"), None)],
+                output: vec![flow("user-input", prim("string"), None)],
+            },
+        );
+        b.register(Ulid::new(), &dht, None);
+        b.register(Ulid::new(), &ui, None);
+
+        let matches = b.all_matches();
+        assert_eq!(matches.len(), 2);
+        // dht.query ← ui.user-input  y  ui.data ← dht.results
+        let pairs: Vec<_> = matches
+            .iter()
+            .map(|m| (m.consumer_label.as_str(), m.producer_label.as_str()))
+            .collect();
+        assert!(pairs.contains(&("dht", "ui")));
+        assert!(pairs.contains(&("ui", "dht")));
+    }
+
+    // ===========================================================
+    // Priority contexts
+    // ===========================================================
+
+    #[test]
+    fn context_priority_offset_lifts_producer_above_alphabetic_winner() {
+        // Sin contexto, "a-prod" gana contra "b-prod" (alfabético).
+        // En contexto "test", b-prod tiene offset +1 → debería ganar.
+        let mut a_prod = card(
+            "a-prod",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        a_prod.priority_contexts = std::collections::BTreeMap::new(); // explícito vacío
+
+        let mut b_prod = card(
+            "b-prod",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        b_prod.priority_contexts.insert(
+            "test".into(),
+            ContextBias {
+                pin_to: None,
+                priority_offset: 1,
+            },
+        );
+
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![],
+            },
+        );
+
+        let s_cons = Ulid::new();
+
+        // Caso 1: sin contexto → a-prod gana (alfabético).
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::default(),
+            current_context: None,
+        });
+        b.register(Ulid::new(), &a_prod, None);
+        b.register(Ulid::new(), &b_prod, None);
+        b.register(s_cons, &consumer, None);
+        let m = b.find_producer_for(s_cons, "in").unwrap();
+        assert_eq!(m.producer_label, "a-prod");
+
+        // Caso 2: contexto "test" → b-prod gana por offset +1.
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::default(),
+            current_context: Some("test".into()),
+        });
+        b.register(Ulid::new(), &a_prod, None);
+        b.register(Ulid::new(), &b_prod, None);
+        b.register(s_cons, &consumer, None);
+        let m = b.find_producer_for(s_cons, "in").unwrap();
+        assert_eq!(m.producer_label, "b-prod");
+    }
+
+    #[test]
+    fn context_pin_to_overrides_static_pin() {
+        // Consumer pinea estático a "real-dht", pero en contexto "test"
+        // declara override a "mock-dht".
+        let real = card(
+            "real-dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let mock = card(
+            "mock-dht",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let mut consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), Some("real-dht"))],
+                output: vec![],
+            },
+        );
+        consumer.priority_contexts.insert(
+            "test".into(),
+            ContextBias {
+                pin_to: Some("mock-dht".into()),
+                priority_offset: 0,
+            },
+        );
+
+        let s_cons = Ulid::new();
+
+        // Caso 1: sin contexto → static pin gana ("real-dht").
+        let mut b = Broker::new(BrokerConfig::default());
+        b.register(Ulid::new(), &real, None);
+        b.register(Ulid::new(), &mock, None);
+        b.register(s_cons, &consumer, None);
+        let m = b.find_producer_for(s_cons, "in").unwrap();
+        assert_eq!(m.producer_label, "real-dht");
+        assert!(m.pinned);
+
+        // Caso 2: contexto "test" → context override gana ("mock-dht").
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::default(),
+            current_context: Some("test".into()),
+        });
+        b.register(Ulid::new(), &real, None);
+        b.register(Ulid::new(), &mock, None);
+        b.register(s_cons, &consumer, None);
+        let m = b.find_producer_for(s_cons, "in").unwrap();
+        assert_eq!(m.producer_label, "mock-dht");
+        assert!(m.pinned);
+    }
+
+    #[test]
+    fn unknown_context_no_op() {
+        // Si la Card declara biases para "test" pero el broker está en
+        // "prod", los biases no aplican.
+        let mut b_prod = card(
+            "b-prod",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        b_prod.priority_contexts.insert(
+            "test".into(),
+            ContextBias {
+                pin_to: None,
+                priority_offset: 5,
+            },
+        );
+        let a_prod = card(
+            "a-prod",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        let consumer = card(
+            "ui",
+            Priority::Normal,
+            Flows {
+                input: vec![flow("in", prim("string"), None)],
+                output: vec![],
+            },
+        );
+
+        let mut b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::default(),
+            current_context: Some("prod".into()),
+        });
+        let s_cons = Ulid::new();
+        b.register(Ulid::new(), &a_prod, None);
+        b.register(Ulid::new(), &b_prod, None);
+        b.register(s_cons, &consumer, None);
+
+        // En contexto "prod" sin biases declarados, gana por alfabético.
+        let m = b.find_producer_for(s_cons, "in").unwrap();
+        assert_eq!(m.producer_label, "a-prod");
+    }
+
+    #[test]
+    fn priority_offset_clamps_to_critical() {
+        // Offset enorme no debe hacer overflow ni saltar fuera del rango.
+        let mut prod = card(
+            "p",
+            Priority::Normal,
+            Flows {
+                input: vec![],
+                output: vec![flow("out", prim("string"), None)],
+            },
+        );
+        prod.priority_contexts.insert(
+            "x".into(),
+            ContextBias {
+                pin_to: None,
+                priority_offset: 100,
+            },
+        );
+
+        let b = Broker::new(BrokerConfig {
+            strategy: MatchStrategy::default(),
+            current_context: Some("x".into()),
+        });
+        let bc = BrokeredCard::from_card(Ulid::new(), &prod, None);
+        // effective_priority debe estar clampada a 3 (Critical), no 101.
+        assert_eq!(b.effective_priority(&bc), 3);
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-card/Cargo.toml b/02_ruway/chasqui/chasqui-card/Cargo.toml
new file mode 100644
index 0000000..a1de45c
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-card/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "chasqui-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de card-core pero para datos."
+
+[dependencies]
+card-core = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/02_ruway/chasqui/chasqui-card/LEEME.md b/02_ruway/chasqui/chasqui-card/LEEME.md
new file mode 100644
index 0000000..3ab1ed3
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-card/LEEME.md
@@ -0,0 +1,10 @@
+# chasqui-card
+
+> Card escritorio (estado del broker) de [chasqui](../README.md).
+
+Stat-card que muestra: broker activo (sí/no), topics activos, mensajes/s. Refresca cada 2s. Usa [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md).
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous`](../chasqui-nous/README.md)
+- [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/chasqui/chasqui-card/README.md b/02_ruway/chasqui/chasqui-card/README.md
new file mode 100644
index 0000000..6d8bf7a
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-card/README.md
@@ -0,0 +1,10 @@
+# chasqui-card
+
+> Desktop card (broker status) of [chasqui](../README.md).
+
+Stat-card showing: broker alive (yes/no), active topics, messages/sec. Refreshes every 2s. Uses [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md).
+
+## Deps
+
+- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous`](../chasqui-nous/README.md)
+- [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/chasqui/chasqui-card/src/lib.rs b/02_ruway/chasqui/chasqui-card/src/lib.rs
new file mode 100644
index 0000000..3712b80
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-card/src/lib.rs
@@ -0,0 +1,431 @@
+//! `chasqui-card` — manifiesto de Mónada.
+//!
+//! Una **Mónada** es una agrupación semántica de archivos: el archivo
+//! físico no se mueve, pero su pertenencia se modela por un objeto
+//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
+//! visualización. La idea hereda el espíritu de la Tarjeta de
+//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
+//! tipado, validado y serializable que define qué es la entidad y
+//! cómo el sistema debe interactuar con ella.
+//!
+//! Diferencia con `brahman-card::Card`:
+//!
+//! | brahman::Card                       | chasqui::MonadManifest         |
+//! |-------------------------------------|-------------------------------|
+//! | Describe una **entidad runtime**    | Describe una **agrupación**   |
+//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás       |
+//! | Vive durante una sesión             | Vive en una DB persistente    |
+//! | Fluye por handshake/postcard        | Fluye por queries del backend |
+//!
+//! Este crate sólo define los tipos. La lógica de scan, cluster,
+//! attraction vive en `chasqui-core`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use ulid::Ulid;
+
+// Re-export para consumidores
+pub use ::ulid;
+
+pub mod query;
+
+/// Versión del esquema del manifiesto. Bump al cambiar el schema.
+pub const MONAD_SCHEMA_VERSION: u16 = 1;
+
+/// Identificador opaco de un archivo registrado en la DB.
+pub type FileId = Ulid;
+
+/// Identificador opaco de una Mónada.
+pub type MonadId = Ulid;
+
+// =====================================================================
+// FileEntry — el archivo como dato indexado
+// =====================================================================
+
+/// Registro físico de un archivo en la DB. Es la unidad atómica que
+/// pertenece a (potencialmente varias) Mónadas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FileEntry {
+    pub id: FileId,
+    pub path: PathBuf,
+    /// Hash de contenido (blake3) — sólo se computa si el archivo es
+    /// chico o el usuario lo pidió. `None` por default en Phase 0.
+    #[serde(default)]
+    pub content_hash: Option<[u8; 32]>,
+    /// Tamaño en bytes.
+    pub size: u64,
+    /// `mtime` como ms desde UNIX_EPOCH.
+    pub mtime_ms: u64,
+    /// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
+    #[serde(default)]
+    pub extension: Option<String>,
+}
+
+// =====================================================================
+// Lens — la "vista" preferida de una Mónada
+// =====================================================================
+
+/// Lente de visualización dominante. La UI (nahual) elige cómo renderizar
+/// los miembros de una Mónada según este hint.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Lens {
+    /// Grid genérico: thumbnail + nombre + meta.
+    #[default]
+    Grid,
+    /// Editor de código con highlighting (rs, py, ts, ...).
+    Code,
+    /// Galería de imágenes (png, jpg, svg, ...).
+    Gallery,
+    /// Vista tabular (csv, sqlite, ...).
+    Database,
+    /// Texto renderizado (md, rst, txt).
+    Markdown,
+    /// Árbol jerárquico (cuando la Mónada es estructural).
+    Tree,
+}
+
+// =====================================================================
+// MonadManifest — la Tarjeta de Presentación de la Mónada
+// =====================================================================
+
+/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
+/// Presentación de Brahman, pero para una agrupación de datos.
+///
+/// Se serializa a JSON/TOML para persistencia y debugging; es el
+/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
+/// el disco.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MonadManifest {
+    /// Versión del esquema. Bump = romper compatibilidad de DB.
+    pub schema_version: u16,
+
+    /// Identificador opaco. ULID — orderable por tiempo de creación.
+    pub id: MonadId,
+
+    /// Mónada de la que ésta fue derivada (split, merge), si aplica.
+    #[serde(default)]
+    pub lineage: Option<MonadId>,
+
+    /// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
+    pub label: String,
+
+    /// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
+    /// la masa de la Mónada justifica la consulta.
+    #[serde(default)]
+    pub summary: String,
+
+    /// Centroide vectorial (embedding promedio de los miembros). Vacío
+    /// en Phase 0 (sin embeddings); se llena cuando entran las
+    /// pseudo-embeddings o el modelo real.
+    #[serde(default)]
+    pub centroid: Vec<f32>,
+
+    /// Identificador del modelo que produjo `centroid`. Si está set, los
+    /// consumidores deben verificar coincidencia antes de comparar vía
+    /// cosine similarity con embeddings recientes; al cambiar de modelo
+    /// (mock-pseudo-32d → real-fastembed-384d, etc.) los centroides
+    /// previos quedan inválidos por dimensión y semántica.
+    /// `None` = legacy (centroides sin tag, pre-versioning).
+    #[serde(default)]
+    pub centroid_model: Option<String>,
+
+    /// Identidad estable derivada del origen de los miembros. Para
+    /// Mónadas creadas por `cluster::by_directory`, es el path
+    /// canónico del directorio padre. Permite que la hidratación
+    /// reuse el mismo ULID across re-scans (mismo path_hint = misma
+    /// identidad, aunque cambien los miembros internamente).
+    /// `None` para Mónadas creadas por estrategias que no se anclan a
+    /// un origen físico.
+    #[serde(default)]
+    pub path_hint: Option<String>,
+
+    /// Tokens dominantes: extensiones, palabras clave, etc.
+    /// 5-10 elementos típicamente.
+    #[serde(default)]
+    pub keywords: Vec<String>,
+
+    /// Cantidad de miembros (== `members.len()`). Cacheado para evitar
+    /// el cost de leer la lista cada vez.
+    pub cardinality: u32,
+
+    /// Métrica de dispersión interna [0.0, 1.0]:
+    /// - 0.0: todos los miembros son muy similares (Mónada coherente).
+    /// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
+    ///
+    /// Calculada como entropía de Shannon normalizada sobre las
+    /// extensiones de los miembros.
+    #[serde(default)]
+    pub entropy: f32,
+
+    /// Lente preferido para visualización en la UI.
+    #[serde(default)]
+    pub dominant_lens: Lens,
+
+    /// Archivos anclados manualmente: NO se mueven en re-clustering
+    /// automático. El usuario "fija" estos miembros.
+    #[serde(default)]
+    pub pins: BTreeSet<FileId>,
+
+    /// IDs de archivos miembros (incluye pins).
+    pub members: BTreeSet<FileId>,
+
+    /// Unix ms de creación de la Mónada.
+    pub created_at_ms: u64,
+
+    /// Unix ms de la última actualización (re-cluster, re-name, ...).
+    pub updated_at_ms: u64,
+
+    /// Forward-compat: campos JSON desconocidos preservados.
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub extensions: BTreeMap<String, serde_json::Value>,
+}
+
+// =====================================================================
+// Errores y validación
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum MonadError {
+    #[error("schema mismatch: got {got}, expected {expected}")]
+    SchemaMismatch { got: u16, expected: u16 },
+    #[error("label vacío")]
+    EmptyLabel,
+    #[error("label demasiado largo: {0} bytes (max 256)")]
+    LabelTooLong(usize),
+    #[error("entropía fuera de [0,1]: {0}")]
+    InvalidEntropy(f32),
+    #[error("Monad sin miembros y sin pins")]
+    Empty,
+    #[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
+    CardinalityMismatch { declared: u32, actual: u32 },
+    #[error("JSON inválido: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+impl MonadManifest {
+    /// Constructor con defaults razonables. `id` y timestamps se
+    /// generan; resto vacío.
+    pub fn new(label: impl Into<String>) -> Self {
+        let now_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+        Self {
+            schema_version: MONAD_SCHEMA_VERSION,
+            id: Ulid::new(),
+            lineage: None,
+            label: label.into(),
+            summary: String::new(),
+            centroid: Vec::new(),
+            centroid_model: None,
+            path_hint: None,
+            keywords: Vec::new(),
+            cardinality: 0,
+            entropy: 0.0,
+            dominant_lens: Lens::default(),
+            pins: BTreeSet::new(),
+            members: BTreeSet::new(),
+            created_at_ms: now_ms,
+            updated_at_ms: now_ms,
+            extensions: BTreeMap::new(),
+        }
+    }
+
+    /// Validación semántica.
+    pub fn validate(&self) -> Result<(), MonadError> {
+        if self.schema_version != MONAD_SCHEMA_VERSION {
+            return Err(MonadError::SchemaMismatch {
+                got: self.schema_version,
+                expected: MONAD_SCHEMA_VERSION,
+            });
+        }
+        if self.label.trim().is_empty() {
+            return Err(MonadError::EmptyLabel);
+        }
+        if self.label.len() > 256 {
+            return Err(MonadError::LabelTooLong(self.label.len()));
+        }
+        if !(0.0..=1.0).contains(&self.entropy) {
+            return Err(MonadError::InvalidEntropy(self.entropy));
+        }
+        if self.members.is_empty() && self.pins.is_empty() {
+            return Err(MonadError::Empty);
+        }
+        let actual = self.members.len() as u32;
+        if self.cardinality != actual {
+            return Err(MonadError::CardinalityMismatch {
+                declared: self.cardinality,
+                actual,
+            });
+        }
+        Ok(())
+    }
+
+    /// Serializa a JSON pretty.
+    pub fn to_json_pretty(&self) -> Result<String, MonadError> {
+        Ok(serde_json::to_string_pretty(self)?)
+    }
+
+    /// Deserializa desde JSON y valida.
+    pub fn from_json(src: &str) -> Result<Self, MonadError> {
+        let m: Self = serde_json::from_str(src)?;
+        m.validate()?;
+        Ok(m)
+    }
+
+    /// Recalcula `cardinality` y `updated_at_ms` desde `members`.
+    /// Usar tras mutaciones del set de miembros.
+    pub fn touch(&mut self) {
+        self.cardinality = self.members.len() as u32;
+        self.updated_at_ms = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_millis() as u64)
+            .unwrap_or(0);
+    }
+
+    /// Proyecta el `MonadManifest` a la `card_core::Card` que viaja
+    /// por el protocolo. La Card resultante:
+    ///
+    /// - hereda `id` y `label` del manifiesto (ULID estable).
+    /// - `kind = CardKind::Data` (se distingue de un Ente).
+    /// - `payload = Virtual`, `supervision = Delegate`,
+    ///   `lifecycle = Daemon` — placeholder semántico: la Mónada no se
+    ///   "ejecuta", el daemon dueño la mantiene viva.
+    /// - `data = Some(DataFacet { ... })` con summary, keywords,
+    ///   centroide, member_count, dispersión y un hint de presentación
+    ///   derivado del `dominant_lens`.
+    /// - Los miembros completos NO viajan en la Card — se consultan al
+    ///   daemon dueño bajo demanda. Lo que viaja es metadata liviana
+    ///   apta para el wire postcard.
+    pub fn to_brahman_card(&self) -> card_core::Card {
+        use card_core::{
+            Card, CardKind, DataFacet, Lifecycle, Payload, Priority, Supervision,
+        };
+
+        let presentation_hint = match self.dominant_lens {
+            Lens::Grid => "grid",
+            Lens::Code => "code",
+            Lens::Gallery => "gallery",
+            Lens::Database => "database",
+            Lens::Markdown => "markdown",
+            Lens::Tree => "tree",
+        }
+        .to_string();
+
+        Card {
+            schema_version: card_core::CARD_SCHEMA_VERSION,
+            id: self.id,
+            label: self.label.clone(),
+            payload: Payload::Virtual,
+            supervision: Supervision::Delegate,
+            lifecycle: Lifecycle::Daemon,
+            priority: Priority::Normal,
+            kind: CardKind::Data,
+            data: Some(DataFacet {
+                summary: self.summary.clone(),
+                keywords: self.keywords.clone(),
+                centroid: self.centroid.clone(),
+                member_count: self.cardinality,
+                dispersion: self.entropy,
+                presentation_hint,
+            }),
+            ..Default::default()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn validates_minimal() {
+        let mut m = MonadManifest::new("test");
+        m.members.insert(Ulid::new());
+        m.touch();
+        m.validate().expect("debe validar");
+    }
+
+    #[test]
+    fn empty_label_rejected() {
+        let mut m = MonadManifest::new("x");
+        m.label = String::new();
+        m.members.insert(Ulid::new());
+        m.touch();
+        assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
+    }
+
+    #[test]
+    fn entropy_out_of_range_rejected() {
+        let mut m = MonadManifest::new("x");
+        m.members.insert(Ulid::new());
+        m.entropy = 1.5;
+        m.touch();
+        assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
+    }
+
+    #[test]
+    fn empty_members_rejected() {
+        let m = MonadManifest::new("x");
+        assert!(matches!(m.validate(), Err(MonadError::Empty)));
+    }
+
+    #[test]
+    fn cardinality_mismatch_caught() {
+        let mut m = MonadManifest::new("x");
+        m.members.insert(Ulid::new());
+        // No llamamos touch — cardinality queda en 0 con 1 miembro.
+        assert!(matches!(
+            m.validate(),
+            Err(MonadError::CardinalityMismatch { .. })
+        ));
+    }
+
+    #[test]
+    fn projects_to_brahman_card() {
+        let mut m = MonadManifest::new("test-monad");
+        m.summary = "monad de prueba".into();
+        m.keywords = vec!["rs".into(), "toml".into()];
+        m.dominant_lens = Lens::Code;
+        m.entropy = 0.42;
+        m.members.insert(Ulid::new());
+        m.members.insert(Ulid::new());
+        m.members.insert(Ulid::new());
+        m.touch();
+
+        let bc = m.to_brahman_card();
+        assert_eq!(bc.id, m.id);
+        assert_eq!(bc.label, "test-monad");
+        assert_eq!(bc.kind, card_core::CardKind::Data);
+        let data = bc.data.expect("data facet presente");
+        assert_eq!(data.summary, "monad de prueba");
+        assert_eq!(data.keywords, vec!["rs".to_string(), "toml".to_string()]);
+        assert_eq!(data.member_count, 3);
+        assert!((data.dispersion - 0.42).abs() < 1e-6);
+        assert_eq!(data.presentation_hint, "code");
+    }
+
+    #[test]
+    fn json_roundtrip() {
+        let mut m = MonadManifest::new("test-monad");
+        m.members.insert(Ulid::new());
+        m.members.insert(Ulid::new());
+        m.keywords = vec!["rs".into(), "toml".into()];
+        m.summary = "test summary".into();
+        m.dominant_lens = Lens::Code;
+        m.touch();
+        let s = m.to_json_pretty().unwrap();
+        let m2 = MonadManifest::from_json(&s).unwrap();
+        assert_eq!(m2.label, m.label);
+        assert_eq!(m2.cardinality, 2);
+        assert_eq!(m2.dominant_lens, Lens::Code);
+        assert_eq!(m2.keywords, m.keywords);
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-card/src/query.rs b/02_ruway/chasqui/chasqui-card/src/query.rs
new file mode 100644
index 0000000..23c15d6
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-card/src/query.rs
@@ -0,0 +1,385 @@
+//! Wire types para consultar al daemon `chasqui` por sus Mónadas.
+//!
+//! El daemon expone un Unix socket (cuyo path se publica en
+//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
+//! conexión es single-shot: una request JSON terminada en `\n`,
+//! una response JSON terminada en `\n`, cierre.
+//!
+//! Mismo patrón que `chasqui-nous` (mock/real ↔ chasqui-core), reusado
+//! ahora para que la UI (`chasqui-explorer`) descubra y consulte al
+//! daemon sin hardcodear sockets ni pasar por brahman-admin.
+//!
+//! ## Contrato
+//!
+//! ```text
+//! C → S: {"kind":"list_monads"}\n
+//! S → C: {"engine":{...},"monads":[...]}\n
+//! ```
+//!
+//! En caso de error:
+//!
+//! ```text
+//! S → C: {"error":"unsupported kind"}\n
+//! ```
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use ulid::Ulid;
+
+use crate::{FileEntry, FileId, Lens, MonadId, MonadManifest};
+
+// =====================================================================
+// Constants compartidos para el broker brahman
+// =====================================================================
+
+/// Nombre del flow output del daemon (input del consumer/explorer).
+pub const FLOW_MONAD_LIST: &str = "monad-list";
+
+/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
+pub const FLOW_TYPE_NAME: &str = "json";
+
+// =====================================================================
+// Wire request
+// =====================================================================
+
+/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
+/// por conexión).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum QueryRequest {
+    /// Lista todas las Mónadas vivas del daemon, junto con metadata
+    /// del engine. Pensado para que la UI haga snapshot polling.
+    ListMonads,
+    /// Resuelve los **archivos miembros** de una Mónada concreta. La vista
+    /// de `ListMonads` es slim (sin member set) para que el poll sea liviano;
+    /// cuando la UI despliega una Mónada en el navegador pide sus archivos
+    /// con esto, bajo demanda. nouser sigue siendo la fuente autoritativa de
+    /// qué archivos componen la Mónada (no el filesystem por su cuenta).
+    ResolveMonad {
+        /// La Mónada cuyos miembros se quieren.
+        id: MonadId,
+    },
+}
+
+// =====================================================================
+// Wire response
+// =====================================================================
+
+/// Response a `ListMonads`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListMonadsResponse {
+    /// Datos del engine (la Card que es "dueña" de las Mónadas).
+    pub engine: EngineInfo,
+    /// Mónadas vivas en este momento. Vista slim sin centroide ni
+    /// member set para que el wire sea liviano: una Mónada con 50k
+    /// archivos no debe transmitir 50k ULIDs cada poll.
+    pub monads: Vec<MonadView>,
+}
+
+/// Identidad del engine (Card kind=Ente que owns las Mónadas).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EngineInfo {
+    pub id: Ulid,
+    pub label: String,
+    /// Path del directorio que el daemon está observando. `None` si
+    /// el daemon corre sin watcher.
+    #[serde(default)]
+    pub watching: Option<String>,
+}
+
+/// Vista slim de una Mónada — los campos que la UI necesita para
+/// renderizar una card sin pull del centroide ni del member set.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MonadView {
+    pub id: MonadId,
+    pub label: String,
+    #[serde(default)]
+    pub summary: String,
+    #[serde(default)]
+    pub keywords: Vec<String>,
+    pub cardinality: u32,
+    #[serde(default)]
+    pub entropy: f32,
+    #[serde(default)]
+    pub dominant_lens: Lens,
+    #[serde(default)]
+    pub path_hint: Option<String>,
+    #[serde(default)]
+    pub centroid_model: Option<String>,
+}
+
+impl MonadView {
+    /// Proyecta un MonadManifest completo a su vista slim para wire.
+    pub fn from_manifest(m: &MonadManifest) -> Self {
+        Self {
+            id: m.id,
+            label: m.label.clone(),
+            summary: m.summary.clone(),
+            keywords: m.keywords.clone(),
+            cardinality: m.cardinality,
+            entropy: m.entropy,
+            dominant_lens: m.dominant_lens,
+            path_hint: m.path_hint.clone(),
+            centroid_model: m.centroid_model.clone(),
+        }
+    }
+}
+
+/// Vista slim de un archivo miembro de una Mónada — lo que la UI necesita
+/// para pintar una fila en el navegador. Omite el `content_hash` (32 bytes
+/// que no se muestran) para que el wire sea liviano.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FileView {
+    pub id: FileId,
+    /// Ruta del archivo, como string (el wire es JSON; `PathBuf` viajaría
+    /// igual pero string es explícito y portable).
+    pub path: String,
+    #[serde(default)]
+    pub size: u64,
+    #[serde(default)]
+    pub extension: Option<String>,
+    #[serde(default)]
+    pub mtime_ms: u64,
+}
+
+impl FileView {
+    /// Proyecta un [`FileEntry`] a su vista slim para wire.
+    pub fn from_entry(f: &FileEntry) -> Self {
+        Self {
+            id: f.id,
+            path: f.path.display().to_string(),
+            size: f.size,
+            extension: f.extension.clone(),
+            mtime_ms: f.mtime_ms,
+        }
+    }
+}
+
+/// Response a [`QueryRequest::ResolveMonad`]: los archivos miembros de la
+/// Mónada pedida. `members` vacío si la Mónada no existe (o no tiene
+/// miembros resolubles) — el daemon no distingue, igual que `resolve_members`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ResolveMonadResponse {
+    /// La Mónada consultada (eco del request, para que la UI confirme).
+    pub monad: MonadId,
+    /// Sus archivos miembros, en el orden que el daemon los entrega.
+    pub members: Vec<FileView>,
+}
+
+/// Error de protocolo retornado en lugar de la response normal.
+#[derive(Debug, Clone, Serialize, Deserialize, Error)]
+#[error("chasqui-engine: {error}")]
+pub struct ErrorResponse {
+    pub error: String,
+}
+
+// =====================================================================
+// Transport
+// =====================================================================
+
+pub mod transport {
+    use std::path::PathBuf;
+
+    /// Variable de entorno para sobreescribir la ruta del socket del
+    /// daemon (útil para tests / multi-daemon).
+    pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
+
+    /// Nombre por defecto del socket.
+    pub const SOCKET_NAME: &str = "chasqui-engine.sock";
+
+    /// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
+    /// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
+    /// `$TMPDIR`).
+    pub fn default_socket_path() -> PathBuf {
+        if let Ok(p) = std::env::var(SOCKET_ENV) {
+            return PathBuf::from(p);
+        }
+        std::env::var_os("XDG_RUNTIME_DIR")
+            .map(PathBuf::from)
+            .unwrap_or_else(std::env::temp_dir)
+            .join(SOCKET_NAME)
+    }
+}
+
+// =====================================================================
+// Cliente blocking — vive con los wire types para que un consumer
+// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo
+// `chasqui-card`, sin arrastrar `chasqui-core` (notify/walkdir/sled/blake3).
+// =====================================================================
+
+/// Cliente síncrono para el query socket del daemon. Sólo Unix (el
+/// resto del ecosistema brahman es Unix-only de facto).
+#[cfg(unix)]
+pub mod client {
+    use std::io::{BufRead, BufReader, Write};
+    use std::os::unix::net::UnixStream;
+    use std::path::Path;
+    use std::time::Duration;
+
+    use serde::de::DeserializeOwned;
+
+    use super::{ErrorResponse, ListMonadsResponse, MonadId, QueryRequest, ResolveMonadResponse};
+
+    #[derive(Debug, thiserror::Error)]
+    pub enum QueryError {
+        #[error("conectar a {path}: {source}")]
+        Connect {
+            path: std::path::PathBuf,
+            #[source]
+            source: std::io::Error,
+        },
+        #[error("I/O: {0}")]
+        Io(#[from] std::io::Error),
+        #[error("serializacion: {0}")]
+        Serde(#[from] serde_json::Error),
+        #[error("daemon: {0}")]
+        Daemon(String),
+        #[error("response vacía del daemon")]
+        Empty,
+    }
+
+    /// Envía un `QueryRequest` al daemon en `socket` y deserializa la
+    /// response al tipo `R`. `timeout` se aplica al read y al write. Si el
+    /// daemon responde un `ErrorResponse`, se devuelve `QueryError::Daemon`.
+    fn request<R: DeserializeOwned>(
+        socket: &Path,
+        req: &QueryRequest,
+        timeout: Duration,
+    ) -> Result<R, QueryError> {
+        let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect {
+            path: socket.to_path_buf(),
+            source: e,
+        })?;
+        stream.set_read_timeout(Some(timeout))?;
+        stream.set_write_timeout(Some(timeout))?;
+
+        let line = serde_json::to_string(req)?;
+        stream.write_all(line.as_bytes())?;
+        stream.write_all(b"\n")?;
+        stream.flush()?;
+
+        let mut reader = BufReader::new(stream);
+        let mut response = String::new();
+        let n = reader.read_line(&mut response)?;
+        if n == 0 {
+            return Err(QueryError::Empty);
+        }
+
+        if let Ok(resp) = serde_json::from_str::<R>(response.trim()) {
+            return Ok(resp);
+        }
+        let err: ErrorResponse = serde_json::from_str(response.trim())?;
+        Err(QueryError::Daemon(err.error))
+    }
+
+    /// Envía `ListMonads` al daemon en `socket` y devuelve la response.
+    /// `timeout` se aplica tanto al read como al write del stream.
+    pub fn list_monads(
+        socket: &Path,
+        timeout: Duration,
+    ) -> Result<ListMonadsResponse, QueryError> {
+        request(socket, &QueryRequest::ListMonads, timeout)
+    }
+
+    /// Pide los archivos miembros de la Mónada `id` al daemon en `socket`.
+    /// Para el nivel de archivos del navegador: la lista slim de `list_monads`
+    /// no los trae, se resuelven bajo demanda al desplegar una Mónada.
+    pub fn resolve_monad(
+        socket: &Path,
+        id: MonadId,
+        timeout: Duration,
+    ) -> Result<ResolveMonadResponse, QueryError> {
+        request(socket, &QueryRequest::ResolveMonad { id }, timeout)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn request_roundtrips_json_with_tag() {
+        let req = QueryRequest::ListMonads;
+        let s = serde_json::to_string(&req).unwrap();
+        assert_eq!(s, r#"{"kind":"list_monads"}"#);
+        let back: QueryRequest = serde_json::from_str(&s).unwrap();
+        assert_eq!(back, req);
+    }
+
+    #[test]
+    fn resolve_monad_request_roundtrips_with_id() {
+        let id = Ulid::new();
+        let req = QueryRequest::ResolveMonad { id };
+        let s = serde_json::to_string(&req).unwrap();
+        assert!(s.contains(r#""kind":"resolve_monad""#), "{s}");
+        let back: QueryRequest = serde_json::from_str(&s).unwrap();
+        assert_eq!(back, req);
+    }
+
+    #[test]
+    fn resolve_monad_response_roundtrip() {
+        use crate::FileEntry;
+        use std::path::PathBuf;
+        let entry = FileEntry {
+            id: Ulid::new(),
+            path: PathBuf::from("/proj/src/lib.rs"),
+            content_hash: None,
+            size: 1234,
+            mtime_ms: 42,
+            extension: Some("rs".into()),
+        };
+        let resp = ResolveMonadResponse {
+            monad: Ulid::new(),
+            members: vec![FileView::from_entry(&entry)],
+        };
+        let s = serde_json::to_string(&resp).unwrap();
+        let back: ResolveMonadResponse = serde_json::from_str(&s).unwrap();
+        assert_eq!(back.members.len(), 1);
+        assert_eq!(back.members[0].path, "/proj/src/lib.rs");
+        assert_eq!(back.members[0].extension.as_deref(), Some("rs"));
+        assert_eq!(back.members[0].size, 1234);
+    }
+
+    #[test]
+    fn response_roundtrip_preserves_view() {
+        let m = MonadManifest::new("x/src");
+        let view = MonadView::from_manifest(&m);
+        let resp = ListMonadsResponse {
+            engine: EngineInfo {
+                id: Ulid::new(),
+                label: "brahman.nouser_engine".into(),
+                watching: Some("/tmp/x".into()),
+            },
+            monads: vec![view.clone()],
+        };
+        let s = serde_json::to_string(&resp).unwrap();
+        let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
+        assert_eq!(back.monads.len(), 1);
+        assert_eq!(back.monads[0].label, view.label);
+        assert_eq!(back.engine.label, "brahman.nouser_engine");
+    }
+
+    #[test]
+    fn view_is_slim_no_centroid_no_members() {
+        // Construimos una Mónada con centroid + members "pesados",
+        // proyectamos a view, verificamos que esos campos no viajan.
+        let mut m = MonadManifest::new("test");
+        m.centroid = vec![0.1; 384]; // peso "real-fastembed"
+        m.members.insert(Ulid::new());
+        m.members.insert(Ulid::new());
+        m.cardinality = 2;
+        let view = MonadView::from_manifest(&m);
+        let s = serde_json::to_string(&view).unwrap();
+        // Chequeo con `:` para distinguir el field "centroid" del
+        // field "centroid_model" (que sí es metadata liviana y debe ir).
+        assert!(
+            !s.contains("\"centroid\":"),
+            "MonadView no debe serializar el vector centroid: {s}"
+        );
+        assert!(
+            !s.contains("\"members\":"),
+            "MonadView no debe serializar members: {s}"
+        );
+        assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-core/Cargo.toml b/02_ruway/chasqui/chasqui-core/Cargo.toml
new file mode 100644
index 0000000..1827a00
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "chasqui-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
+
+[dependencies]
+chasqui-card = { path = "../chasqui-card" }
+chasqui-nous = { path = "../chasqui-nous" }
+shuma-discern = { workspace = true }
+card-core = { workspace = true }
+card-handshake = { path = "../card-handshake" }
+card-sidecar = { path = "../card-sidecar" }
+blake3 = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sled = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+ulid = { workspace = true }
+walkdir = "2"
+notify = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+
+[[bin]]
+name = "chasqui"
+path = "src/bin/nouser.rs"
diff --git a/02_ruway/chasqui/chasqui-core/LEEME.md b/02_ruway/chasqui/chasqui-core/LEEME.md
new file mode 100644
index 0000000..3b61846
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/LEEME.md
@@ -0,0 +1,9 @@
+# chasqui-core
+
+> Tipos compartidos de [chasqui](../README.md): `Topic`, `Message`, `Schema`, `Subscription`.
+
+Núcleo sin transport, sin red. Lo importan tanto el broker como cualquier cliente.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/chasqui/chasqui-core/README.md b/02_ruway/chasqui/chasqui-core/README.md
new file mode 100644
index 0000000..00a7768
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/README.md
@@ -0,0 +1,9 @@
+# chasqui-core
+
+> Shared types of [chasqui](../README.md): `Topic`, `Message`, `Schema`, `Subscription`.
+
+Core without transport or network. Imported by both the broker and any client.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs b/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs
new file mode 100644
index 0000000..f47087f
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs
@@ -0,0 +1,795 @@
+//! `chasqui` CLI — explorador de Mónadas.
+//!
+//! Subcomandos:
+//!
+//! - `scan <dir>`        recorre `dir` y muestra las Mónadas detectadas.
+//! - `show <dir> <id?>`  scan + detalles de la Mónada con prefijo de ID.
+//! - `json <dir>`        scan + dump JSON con los manifests.
+//!
+//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
+//! sesión termina y todo se descarta. Phase B agrega persistencia y
+//! presencia ante el Init.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use chasqui_core::{
+    cluster, db, embed,
+    scanner::{self, ScanConfig},
+};
+
+fn main() -> ExitCode {
+    let args: Vec<String> = std::env::args().collect();
+    let prog = args.first().cloned().unwrap_or_else(|| "chasqui".into());
+    let sub = match args.get(1).map(String::as_str) {
+        Some(s) => s,
+        None => {
+            print_usage(&prog);
+            return ExitCode::from(2);
+        }
+    };
+    let rest = &args[2..];
+
+    let result = match sub {
+        "scan" => cmd_scan(rest),
+        "show" => cmd_show(rest),
+        "json" => cmd_json(rest),
+        "daemon" => cmd_daemon(rest),
+        "attract" => cmd_attract(rest),
+        "--help" | "-h" | "help" => {
+            print_usage(&prog);
+            return ExitCode::SUCCESS;
+        }
+        other => {
+            eprintln!("chasqui: comando desconocido '{other}'");
+            print_usage(&prog);
+            return ExitCode::from(2);
+        }
+    };
+
+    match result {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(e) => {
+            eprintln!("chasqui: {e}");
+            ExitCode::from(1)
+        }
+    }
+}
+
+fn print_usage(prog: &str) {
+    eprintln!("uso: {prog} <comando> [args]");
+    eprintln!();
+    eprintln!("comandos:");
+    eprintln!("  scan <dir>           recorre un directorio y lista las Mónadas detectadas");
+    eprintln!("  show <dir> <prefix>  scan + detalle de la Mónada cuyo ID empieza con <prefix>");
+    eprintln!("  json <dir>           scan + dump JSON de todos los manifests");
+    eprintln!("  daemon <dir>         scan + sidecarea cada Mónada al Init brahman");
+    eprintln!("  attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
+    eprintln!();
+    eprintln!("env:");
+    eprintln!("  NOUSER_MIN_FILES         mínimo de archivos por Mónada (default: 3)");
+    eprintln!("  NOUSER_DB_PATH           si está set, abre sled en esa ruta (persistencia)");
+    eprintln!("  BRAHMAN_INIT_SOCKET      socket del Init (heredado de brahman-handshake)");
+}
+
+type Cmd = Result<(), Box<dyn std::error::Error>>;
+
+fn min_files() -> usize {
+    std::env::var("NOUSER_MIN_FILES")
+        .ok()
+        .and_then(|s| s.parse().ok())
+        .unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
+}
+
+fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
+    let dir = args.first().ok_or("falta argumento <dir>")?;
+    Ok(PathBuf::from(dir))
+}
+
+fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
+    let files = scanner::scan_directory(dir, &ScanConfig::default())?;
+    let n_files = files.len();
+    let monads = cluster::by_directory(&files, min_files());
+    let mut db = open_db()?;
+    db.ingest_files(files);
+    db.replace_monads(monads);
+    Ok((db, n_files))
+}
+
+/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
+/// si no, store en memoria.
+fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
+    if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
+        Ok(db::MonadDb::open(&path)?)
+    } else {
+        Ok(db::MonadDb::new())
+    }
+}
+
+fn cmd_scan(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let (db, n_files) = run_scan(&dir)?;
+
+    println!(
+        "scan: {} archivos en {}, {} mónadas (min_files={})",
+        n_files,
+        dir.display(),
+        db.monad_count(),
+        min_files()
+    );
+    if db.monad_count() == 0 {
+        println!("  (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
+        return Ok(());
+    }
+    println!();
+    for m in db.monads() {
+        let id_short = format!("{}", m.id);
+        let id_short = &id_short[..8];
+        println!(
+            "  [{}]  {:30}  card={}  ent={:.2}  lens={:?}",
+            id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
+        );
+        if !m.keywords.is_empty() {
+            println!("           keywords: {}", m.keywords.join(", "));
+        }
+    }
+    Ok(())
+}
+
+fn cmd_show(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
+    let (db, _) = run_scan(&dir)?;
+
+    let m = db
+        .monads()
+        .find(|m| m.id.to_string().starts_with(prefix))
+        .ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
+
+    println!("Monad {}", m.id);
+    println!("  label:       {}", m.label);
+    println!("  summary:     {}", m.summary);
+    println!("  cardinality: {}", m.cardinality);
+    println!("  entropy:     {:.4}", m.entropy);
+    println!("  lens:        {:?}", m.dominant_lens);
+    println!("  keywords:    {}", m.keywords.join(", "));
+    println!("  members ({}):", m.members.len());
+    for f in db.resolve_members(m.id) {
+        println!(
+            "    {:>10} bytes  {}",
+            f.size,
+            f.path.display()
+        );
+    }
+    Ok(())
+}
+
+fn cmd_json(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+    let (db, _) = run_scan(&dir)?;
+    let manifests: Vec<_> = db.monads().cloned().collect();
+    println!("{}", serde_json::to_string_pretty(&manifests)?);
+    Ok(())
+}
+
+fn cmd_daemon(args: &[String]) -> Cmd {
+    let dir = require_dir(args)?;
+
+    let pool = std::sync::Arc::new(
+        card_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
+    );
+
+    // 1. Decidir el path del query socket ANTES de armar el engine
+    //    Card (porque viaja como service_socket en la Card).
+    let query_socket = chasqui_card::query::transport::default_socket_path();
+
+    // 2. Engine como Ente. Declara service_socket + flow.output para
+    //    que el broker pueda emitir MatchEvent::Available a consumers
+    //    interesados en `flow.input = monad-list:json`.
+    let engine_card = build_engine_card(query_socket.clone());
+    let engine_id = engine_card.id;
+    let engine_label = engine_card.label.clone();
+    eprintln!(
+        "chasqui daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
+        engine_label,
+        engine_id,
+        query_socket.display()
+    );
+    pool.spawn(engine_card);
+
+    // 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
+    //    publicar lo que ya tenemos ANTES del re-scan. brahman-status
+    //    ve mónadas reales en milisegundos, no en segundos.
+    let mut db = open_db()?;
+    let prior_count = db.monad_count();
+    if prior_count > 0 {
+        let mut hydrated = 0usize;
+        let mut skipped_model = 0usize;
+        for monad in db.monads() {
+            // Sólo publicamos centroides del modelo actual; los demás
+            // son data muerta hasta que el re-scan los reemplace.
+            let valid = monad
+                .centroid_model
+                .as_deref()
+                .map(|id| id == embed::MODEL_ID)
+                .unwrap_or(false);
+            if !valid {
+                skipped_model += 1;
+                continue;
+            }
+            let mut card = monad.to_brahman_card();
+            card.references.push(card_core::CardReference {
+                kind: card_core::RelationshipKind::OwnedBy,
+                target_id: engine_id,
+                target_label: engine_label.clone(),
+            });
+            pool.spawn(card);
+            hydrated += 1;
+        }
+        eprintln!(
+            "chasqui daemon: hidratadas {} mónadas previas{} en O(1)",
+            hydrated,
+            if skipped_model > 0 {
+                format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
+            } else {
+                String::new()
+            }
+        );
+    }
+
+    // 3. Re-scan con hidratación: las Mónadas con mismo path_hint
+    //    reusan id, así que NO generamos sesiones duplicadas para los
+    //    mismos directorios — el sidecar previo ya tiene esa identidad.
+    let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
+    let n_files = files.len();
+    let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
+    let scanned_count = monads.len();
+    eprintln!(
+        "chasqui daemon: re-scan {} archivos en {} → {} mónadas",
+        n_files,
+        dir.display(),
+        scanned_count
+    );
+
+    // Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
+    // hidratación inicial). El criterio: si el id estaba en la DB
+    // previa, el sidecar de la hidratación ya cubre esa identidad.
+    let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
+    let mut newly_spawned = 0usize;
+    for monad in &monads {
+        if prior_ids.contains(&monad.id) {
+            continue; // ya publicada en hidratación
+        }
+        let mut card = monad.to_brahman_card();
+        card.references.push(card_core::CardReference {
+            kind: card_core::RelationshipKind::OwnedBy,
+            target_id: engine_id,
+            target_label: engine_label.clone(),
+        });
+        pool.spawn(card);
+        newly_spawned += 1;
+    }
+
+    // Reescribimos la DB con el set actual (idempotente para los
+    // hidratados; reemplazo para los nuevos).
+    db.ingest_files(files);
+    db.replace_monads(monads);
+
+    eprintln!(
+        "chasqui daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
+        scanned_count, newly_spawned
+    );
+
+    // Engine query socket: bind antes del watcher para que cualquier
+    // consumer descubierto vía broker pueda consultarnos enseguida.
+    // Si el bind falla, seguimos sin él — la UI degrada a "no
+    // alcanzable" pero el daemon sigue procesando cambios.
+    let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
+    let _query_listener = match chasqui_core::engine_socket::spawn_listener(
+        chasqui_core::engine_socket::ListenerConfig {
+            socket_path: query_socket.clone(),
+            engine_id,
+            engine_label: engine_label.clone(),
+            watching: Some(dir.clone()),
+        },
+        db_shared.clone(),
+    ) {
+        Ok(h) => {
+            eprintln!(
+                "chasqui daemon: query socket activo en {} (proto: chasqui_card::query)",
+                query_socket.display()
+            );
+            Some(h)
+        }
+        Err(e) => {
+            eprintln!(
+                "chasqui daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
+            );
+            None
+        }
+    };
+
+    // Watcher: cada cambio en el árbol — coalescido con debounce de
+    // 150ms — dispara un re-scan + re-cluster del directorio y
+    // re-publica al broker las Mónadas afectadas (drop + spawn por id,
+    // gracias al replace en `SidecarPool::spawn`).
+    let _watcher = match spawn_fs_watcher(
+        dir.clone(),
+        db_shared.clone(),
+        pool.clone(),
+        engine_id,
+        engine_label.clone(),
+    ) {
+        Ok(w) => {
+            eprintln!(
+                "chasqui daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
+                dir.display()
+            );
+            Some(w)
+        }
+        Err(e) => {
+            eprintln!(
+                "chasqui daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
+            );
+            None
+        }
+    };
+
+    std::thread::park();
+    drop(_watcher);
+    drop(_query_listener);
+    let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
+    drop(pool);
+    Ok(())
+}
+
+/// Ventana de debounce: notify dispara Create+Modify(+) por cada
+/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
+/// 150ms es generoso para editores típicos (vim/code) y mantiene el
+/// feedback "vivo" para el usuario.
+const WATCHER_DEBOUNCE_MS: u64 = 150;
+
+/// Watcher de filesystem con debounce + re-publish al broker.
+///
+/// Pipeline:
+///
+/// 1. **notify** dispara eventos crudos a un canal interno.
+/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
+///    `dir`, descarta el resto, reenvía al canal de debounce.
+/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
+///    Cada vez que el canal queda en silencio durante
+///    `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
+///    superó la ventana y los procesa en **un solo batch**.
+/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
+///    diff vs DB + `pool.drop_session` para Mónadas desaparecidas
+///    + `pool.spawn` para Mónadas nuevas o con composición distinta.
+///    `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
+///    así que el broker ve el manifest fresco sin sesiones huérfanas.
+fn spawn_fs_watcher(
+    dir: std::path::PathBuf,
+    db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
+    pool: std::sync::Arc<card_sidecar::SidecarPool>,
+    engine_id: card_core::ulid::Ulid,
+    engine_label: String,
+) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
+    use notify::{Event, EventKind, RecursiveMode, Watcher};
+
+    let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
+    let mut watcher = notify::recommended_watcher(move |res| {
+        let _ = notify_tx.send(res);
+    })?;
+    watcher.watch(&dir, RecursiveMode::Recursive)?;
+
+    let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
+
+    // Dispatcher: notify → filtro → canal de paths.
+    let dispatch_dir = dir.clone();
+    std::thread::Builder::new()
+        .name("chasqui-watcher-dispatch".into())
+        .spawn(move || {
+            for res in notify_rx {
+                let event = match res {
+                    Ok(e) => e,
+                    Err(e) => {
+                        eprintln!("[watcher] error: {e}");
+                        continue;
+                    }
+                };
+                // Create/Modify viven; Remove también nos importa
+                // (puede colapsar Mónadas).
+                let interesting = matches!(
+                    event.kind,
+                    EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
+                );
+                if !interesting {
+                    continue;
+                }
+                for path in event.paths {
+                    if !path.starts_with(&dispatch_dir) {
+                        continue;
+                    }
+                    let _ = path_tx.send(path);
+                }
+            }
+        })?;
+
+    // Coordinator: debounce + batch dispatch.
+    let coord_dir = dir;
+    std::thread::Builder::new()
+        .name("chasqui-watcher-coord".into())
+        .spawn(move || {
+            let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
+            let mut pending: std::collections::HashMap<
+                std::path::PathBuf,
+                std::time::Instant,
+            > = std::collections::HashMap::new();
+            loop {
+                match path_rx.recv_timeout(debounce) {
+                    Ok(path) => {
+                        pending.insert(path, std::time::Instant::now());
+                    }
+                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
+                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
+                }
+                let now = std::time::Instant::now();
+                let due: Vec<std::path::PathBuf> = pending
+                    .iter()
+                    .filter(|(_, t)| now.duration_since(**t) >= debounce)
+                    .map(|(p, _)| p.clone())
+                    .collect();
+                if due.is_empty() {
+                    continue;
+                }
+                for p in &due {
+                    pending.remove(p);
+                }
+                process_change_batch(
+                    &due,
+                    &coord_dir,
+                    &db,
+                    &pool,
+                    engine_id,
+                    &engine_label,
+                );
+            }
+        })?;
+
+    Ok(watcher)
+}
+
+/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
+/// con hidratación, y propaga el delta de Mónadas al broker.
+///
+/// El re-scan global es deliberado: el clustering por directorio es global
+/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
+/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
+/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
+/// cuando duela.
+fn process_change_batch(
+    paths: &[std::path::PathBuf],
+    dir: &std::path::Path,
+    db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
+    pool: &std::sync::Arc<card_sidecar::SidecarPool>,
+    engine_id: card_core::ulid::Ulid,
+    engine_label: &str,
+) {
+    eprintln!(
+        "[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
+        paths.len()
+    );
+
+    let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
+        Ok(f) => f,
+        Err(e) => {
+            eprintln!("[watcher] re-scan falló: {e}");
+            return;
+        }
+    };
+
+    let mut db_lock = match db.lock() {
+        Ok(g) => g,
+        Err(_) => {
+            eprintln!("[watcher] mutex envenenado — abortando batch");
+            return;
+        }
+    };
+
+    let prior_monads: Vec<chasqui_card::MonadManifest> = db_lock.monads().cloned().collect();
+    let prior_ref: &db::MonadDb = &db_lock;
+    let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
+
+    let prior_ids: std::collections::BTreeSet<_> =
+        prior_monads.iter().map(|m| m.id).collect();
+    let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
+
+    // Mónadas que ya no existen (directorio quedó por debajo de
+    // min_files o fue removido): cerramos su sesión en el broker.
+    let mut removed = 0usize;
+    for id in prior_ids.difference(&new_ids) {
+        pool.drop_session(*id);
+        removed += 1;
+        if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
+            eprintln!(
+                "[watcher] ✖ {} ({}) desapareció — sesión cerrada",
+                &id.to_string()[..8],
+                prev.label
+            );
+        }
+    }
+
+    // Mónadas nuevas o cuya composición cambió (members/centroid):
+    // (re)spawn — el pool reemplaza la sesión previa con el mismo id.
+    let mut respawned = 0usize;
+    let mut fresh = 0usize;
+    for monad in &monads {
+        let prev = prior_monads.iter().find(|m| m.id == monad.id);
+        let is_new = prev.is_none();
+        let changed = match prev {
+            Some(p) => p.members != monad.members || p.centroid != monad.centroid,
+            None => true,
+        };
+        if !changed {
+            continue;
+        }
+        let mut card = monad.to_brahman_card();
+        card.references.push(card_core::CardReference {
+            kind: card_core::RelationshipKind::OwnedBy,
+            target_id: engine_id,
+            target_label: engine_label.to_string(),
+        });
+        pool.spawn(card);
+        if is_new {
+            fresh += 1;
+            eprintln!(
+                "[watcher] ✦ {} nace ({} miembros, lens={:?})",
+                monad.label, monad.cardinality, monad.dominant_lens
+            );
+        } else {
+            respawned += 1;
+            let prev = prev.unwrap();
+            let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
+            eprintln!(
+                "[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
+                monad.label, monad.cardinality, delta_members
+            );
+        }
+    }
+
+    if removed == 0 && fresh == 0 && respawned == 0 {
+        eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
+    } else {
+        eprintln!(
+            "[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
+            fresh,
+            respawned,
+            removed,
+            pool.live_sessions()
+        );
+    }
+
+    db_lock.ingest_files(files);
+    db_lock.replace_monads(monads);
+}
+
+fn cmd_attract(args: &[String]) -> Cmd {
+    let mut remote = false;
+    let mut positional: Vec<&String> = Vec::new();
+    for a in args {
+        if a == "--remote" {
+            remote = true;
+        } else {
+            positional.push(a);
+        }
+    }
+    let dir = positional
+        .first()
+        .map(|s| std::path::PathBuf::from(s.as_str()))
+        .ok_or("falta argumento <dir>")?;
+    let file_path = positional.get(1).ok_or("falta argumento <file>")?;
+    let file_path = std::path::PathBuf::from(file_path.as_str());
+    if !file_path.exists() {
+        return Err(format!("archivo no existe: {}", file_path.display()).into());
+    }
+
+    let (db, _) = run_scan(&dir)?;
+
+    // Construimos un FileEntry para el archivo objetivo.
+    let metadata = std::fs::metadata(&file_path)?;
+    let mtime_ms = metadata
+        .modified()
+        .ok()
+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0);
+    let target = chasqui_card::FileEntry {
+        id: chasqui_card::FileId::from(chasqui_card::ulid::Ulid::new()),
+        path: file_path.clone(),
+        content_hash: None,
+        size: metadata.len(),
+        mtime_ms,
+        extension: file_path
+            .extension()
+            .and_then(|s| s.to_str())
+            .map(|s| s.to_lowercase()),
+    };
+
+    // Embedding del target + identificación del modelo que lo produjo.
+    // Local: pseudo-32d. Remote: lo que devuelva el provider electo
+    // (mock=pseudo-32d, real=fastembed-384d).
+    let (target_vec, target_model, source) = if remote {
+        let (v, model) = remote_embed(&target)?;
+        (v, model, "remote")
+    } else {
+        (
+            embed::embed(&target).to_vec(),
+            embed::MODEL_ID.to_string(),
+            "local",
+        )
+    };
+
+    // Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
+    // 32-d con 384-d daría scores sin sentido (diferente semántica
+    // y cosine no compara cross-modelo).
+    let mut ranked: Vec<(&chasqui_card::MonadManifest, f32)> = db
+        .monads()
+        .filter(|m| !m.centroid.is_empty())
+        .filter(|m| match &m.centroid_model {
+            Some(id) => id == &target_model,
+            None => true, // legacy sin tag — comparamos best-effort
+        })
+        .map(|m| (m, embed::attraction_score(&target_vec, m)))
+        .collect();
+    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
+
+    let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
+    let skipped = total_monads - ranked.len();
+
+    if ranked.is_empty() {
+        println!("ninguna Mónada con centroide en {}", dir.display());
+        return Ok(());
+    }
+
+    println!("archivo:   {}", file_path.display());
+    println!("scan dir:  {}", dir.display());
+    println!("embed:     {} ({})", source, target_model);
+    if skipped > 0 {
+        println!(
+            "skipped:   {} mónada(s) con centroid_model distinto (no comparables)",
+            skipped
+        );
+    }
+    println!("ranking de atracción (cosine similarity):");
+    println!();
+    for (i, (m, score)) in ranked.iter().take(5).enumerate() {
+        let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
+            "🧲"
+        } else if i == 0 {
+            "·"
+        } else {
+            " "
+        };
+        let id_short = format!("{}", m.id);
+        let id_short = &id_short[..8];
+        println!(
+            "  {}  {:.4}  [{}]  {:30}  ({})",
+            marker, score, id_short, m.label, m.summary
+        );
+    }
+    if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
+        println!();
+        println!(
+            "  (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
+            ranked[0].1,
+            embed::DEFAULT_ATTRACTION_THRESHOLD
+        );
+    }
+    Ok(())
+}
+
+/// Pipeline completo del modo `--remote`:
+/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
+///    explícito, atajo para tests).
+/// 2. Si no, delega en `card_sidecar::await_provider_blocking` —
+///    el sidecar se conecta al broker, registra un consumer Card con
+///    `flow.input = embed-result:json`, espera el primer
+///    `MatchEvent::Available` y devuelve el socket. Esto activa la
+///    lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
+///    el proveedor electo cambia sin que este código toque nada.
+/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
+///
+/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
+/// comparar contra centroides taggeados con su mismo `centroid_model`.
+fn remote_embed(
+    file: &chasqui_card::FileEntry,
+) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
+    if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
+        let sock = std::path::PathBuf::from(explicit);
+        return embed_via(&sock, file);
+    }
+
+    let consumer = card_sidecar::build_consumer_card(
+        "chasqui.attract-cli",
+        chasqui_nous::FLOW_EMBED_RESULT,
+        chasqui_nous::FLOW_TYPE_NAME,
+    );
+    let producer_sock = card_sidecar::await_provider_blocking(
+        consumer,
+        std::time::Duration::from_secs(3),
+    )?;
+    embed_via(&producer_sock, file)
+}
+
+/// RPC blocking contra un socket chasqui-nous concreto. Devuelve
+/// `(embedding, model_id)` — el `model_id` viaja en la response y
+/// permite al caller saber qué centroides son comparables.
+fn embed_via(
+    sock_path: &std::path::Path,
+    file: &chasqui_card::FileEntry,
+) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
+    use std::io::{BufRead, BufReader, Write};
+    use std::os::unix::net::UnixStream;
+
+    if !sock_path.exists() {
+        return Err(format!("socket no existe: {}", sock_path.display()).into());
+    }
+
+    let mut stream = UnixStream::connect(sock_path)?;
+    let req = chasqui_nous::EmbedRequest {
+        kind: chasqui_nous::RequestKind::EmbedFile,
+        payload: serde_json::to_value(chasqui_nous::EmbedFilePayload {
+            path: file.path.display().to_string(),
+            extension: file.extension.clone(),
+            size: file.size,
+            mtime_ms: file.mtime_ms,
+        })?,
+    };
+    let line = serde_json::to_string(&req)?;
+    stream.write_all(line.as_bytes())?;
+    stream.write_all(b"\n")?;
+    stream.flush()?;
+
+    let mut reader = BufReader::new(stream);
+    let mut response = String::new();
+    reader.read_line(&mut response)?;
+    if response.is_empty() {
+        return Err("chasqui-nous cerró sin respuesta".into());
+    }
+
+    if let Ok(resp) = serde_json::from_str::<chasqui_nous::EmbedResponse>(&response) {
+        return Ok((resp.embedding, resp.model));
+    }
+    let err: chasqui_nous::ErrorResponse = serde_json::from_str(&response)?;
+    Err(format!("chasqui-nous: {}", err.error).into())
+}
+
+/// Card del propio engine (kind=Ente). Es el "ser" que produce y
+/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
+///
+/// Declara `service_socket` y `flow.output = monad-list:json` para
+/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
+/// MatchEvent y consultarle por sus Mónadas sin pasar por
+/// brahman-admin.
+fn build_engine_card(service_socket: std::path::PathBuf) -> card_core::Card {
+    use card_core::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
+    use chasqui_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
+
+    Card {
+        payload: Payload::Virtual,
+        supervision: Supervision::Delegate,
+        lifecycle: Lifecycle::Daemon,
+        priority: Priority::Normal,
+        kind: CardKind::Ente,
+        service_socket: Some(service_socket),
+        flow: Flows {
+            input: vec![],
+            output: vec![Flow {
+                name: FLOW_MONAD_LIST.into(),
+                ty: TypeRef::Primitive {
+                    name: FLOW_TYPE_NAME.into(),
+                },
+                pin_to: None,
+            }],
+        },
+        ..Card::new("brahman.nouser_engine")
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-core/src/cluster.rs b/02_ruway/chasqui/chasqui-core/src/cluster.rs
new file mode 100644
index 0000000..85af5b5
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/src/cluster.rs
@@ -0,0 +1,355 @@
+//! Clustering determinista (Phase A).
+//!
+//! Estrategia: agrupar por **directorio padre** + ranking por
+//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
+//! Esta capa cubre el 90% de los casos prácticos:
+//!
+//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
+//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
+//! - Notas en `~/notes/` → Mónada con lente Markdown.
+//!
+//! Los casos donde esta heurística falla (archivos relacionados pero
+//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
+//! del clustering por Nous (Phase D).
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use chasqui_card::{FileEntry, Lens, MonadManifest};
+
+use crate::embed;
+
+/// Mínimo de archivos para que un directorio sea promovido a Mónada.
+/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
+pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
+
+/// Agrupa archivos en Mónadas por directorio padre.
+///
+/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
+/// directorios con menos de `min_files` no producen Mónada.
+pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
+    by_directory_hydrated(files, min_files, None)
+}
+
+/// Variante con hidratación: si `prior` está presente, busca Mónadas
+/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
+/// su `id` y `lineage`. Esto preserva identidad across re-scans —
+/// fundamental para que el daemon pueda republicar tras hidratar de
+/// sled sin generar duplicados en el broker.
+pub fn by_directory_hydrated(
+    files: &[FileEntry],
+    min_files: usize,
+    prior: Option<&crate::db::MonadDb>,
+) -> Vec<MonadManifest> {
+    let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
+    for f in files {
+        if let Some(parent) = f.path.parent() {
+            by_parent.entry(parent.to_path_buf()).or_default().push(f);
+        }
+    }
+
+    let mut out = Vec::new();
+    for (parent, group) in by_parent {
+        if group.len() < min_files {
+            continue;
+        }
+        let mut m = build_monad(&parent, &group);
+        if let Some(db) = prior {
+            // Reusamos id si encontramos Mónada previa con mismo
+            // path_hint Y mismo centroid_model. Distintas hipótesis
+            // de modelo no comparten identidad — son objetos
+            // semánticos distintos, aunque parecidos.
+            if let Some(existing) = db.monads().find(|prev| {
+                prev.path_hint.as_deref() == m.path_hint.as_deref()
+                    && prev.centroid_model == m.centroid_model
+            }) {
+                m.id = existing.id;
+                m.lineage = existing.lineage;
+                m.created_at_ms = existing.created_at_ms;
+                m.touch();
+            }
+        }
+        out.push(m);
+    }
+    out
+}
+
+fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
+    let label = label_from_path(parent);
+
+    let keywords = top_extensions(group, 5);
+    let lens = pick_lens(group);
+    let entropy = shannon_entropy_normalized(group);
+
+    let summary = build_summary(parent, group, &keywords);
+
+    // Centroide vectorial: promedio de los embeddings de los miembros.
+    // Esto es lo que permite "atracción" determinista de archivos
+    // nuevos sin tocar Nous.
+    let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
+    let centroid = embed::centroid(&member_vecs);
+
+    let mut m = MonadManifest::new(label);
+    m.summary = summary;
+    m.keywords = keywords;
+    m.dominant_lens = lens;
+    m.entropy = entropy;
+    m.centroid = centroid;
+    // Taggeamos el centroide con su modelo. attract verifica esto
+    // antes de comparar para no mezclar pseudo-32d con real-384d.
+    m.centroid_model = Some(embed::MODEL_ID.to_string());
+    // path_hint = identidad estable across re-scans para
+    // hidratación. Display es lossy con UTF-8 inválido pero los
+    // paths legítimos se imprimen consistentes.
+    m.path_hint = Some(parent.display().to_string());
+    m.members = group.iter().map(|f| f.id).collect();
+    m.touch();
+    m
+}
+
+/// Construye un label legible tomando los últimos hasta 2 componentes
+/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
+/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
+/// etc. Para directorios shallow (root o un nivel), cae al
+/// `file_name()` simple.
+fn label_from_path(p: &std::path::Path) -> String {
+    let normals: Vec<&str> = p
+        .components()
+        .filter_map(|c| match c {
+            std::path::Component::Normal(s) => s.to_str(),
+            _ => None,
+        })
+        .collect();
+    if normals.is_empty() {
+        return "unnamed".to_string();
+    }
+    let take = normals.len().min(2);
+    let start = normals.len() - take;
+    normals[start..].join("/")
+}
+
+fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
+    let path_str = parent.display();
+    let n = group.len();
+    let exts = if keywords.is_empty() {
+        "(sin extensiones)".to_string()
+    } else {
+        keywords.join(", ")
+    };
+    format!("{n} archivos en {path_str} (ext: {exts})")
+}
+
+/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
+fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+    for f in files {
+        if let Some(ext) = &f.extension {
+            *counts.entry(ext.clone()).or_default() += 1;
+        }
+    }
+    let mut sorted: Vec<_> = counts.into_iter().collect();
+    sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
+    sorted.into_iter().take(n).map(|(k, _)| k).collect()
+}
+
+/// Elige el lente dominante según la extensión más frecuente, con
+/// fallback a `shuma-discern` sobre el head del archivo más
+/// representativo cuando la extensión no da hint claro (Lens::Grid).
+fn pick_lens(files: &[&FileEntry]) -> Lens {
+    let dominant = top_extensions(files, 1).into_iter().next();
+    let by_ext = match dominant.as_deref() {
+        Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
+        | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
+        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
+            Lens::Gallery
+        }
+        Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
+        Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
+        _ => Lens::Grid,
+    };
+    if by_ext != Lens::Grid {
+        return by_ext;
+    }
+    // Fallback: samplear el primer archivo del grupo con shuma-discern.
+    // Sólo si tiene path real (FileEntry con path absoluto/relativo).
+    if let Some(first) = files.first() {
+        if let Some(lens) = discern_lens(&first.path) {
+            return lens;
+        }
+    }
+    Lens::Grid
+}
+
+fn discern_lens(path: &std::path::Path) -> Option<Lens> {
+    use std::io::Read;
+    let mut buf = vec![0u8; 4096];
+    let mut f = std::fs::File::open(path).ok()?;
+    let n = f.read(&mut buf).ok()?;
+    buf.truncate(n);
+    let pipeline = shuma_discern::DiscernPipeline::default_pipeline();
+    let path_str = path.to_str();
+    let d = pipeline.discern(
+        &buf,
+        &shuma_discern::Hint {
+            path: path_str,
+            size_total: None,
+        },
+    )?;
+    match d.lens.as_deref()? {
+        "code" => Some(Lens::Code),
+        "gallery" => Some(Lens::Gallery),
+        "markdown" => Some(Lens::Markdown),
+        "database" => Some(Lens::Database),
+        "tree" => Some(Lens::Tree),
+        _ => None,
+    }
+}
+
+/// Entropía de Shannon normalizada sobre la distribución de extensiones.
+/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
+/// distributed entre `n` extensiones (máx información).
+fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
+    let total = files.len() as f32;
+    if total <= 1.0 {
+        return 0.0;
+    }
+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+    for f in files {
+        let ext = f.extension.as_deref().unwrap_or("(none)");
+        *counts.entry(ext.to_string()).or_default() += 1;
+    }
+    let entropy: f32 = counts
+        .values()
+        .map(|&c| {
+            let p = c as f32 / total;
+            -p * p.log2()
+        })
+        .sum();
+    let max_entropy = (counts.len() as f32).log2();
+    if max_entropy <= 0.0 {
+        0.0
+    } else {
+        (entropy / max_entropy).clamp(0.0, 1.0)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chasqui_card::FileId;
+    use std::path::PathBuf;
+    use ulid::Ulid;
+
+    fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: PathBuf::from(path),
+            content_hash: None,
+            size: 100,
+            mtime_ms: 0,
+            extension: ext.map(String::from),
+        }
+    }
+
+    #[test]
+    fn groups_by_parent_directory() {
+        let files = vec![
+            mkfile("/proj/src/a.rs", Some("rs")),
+            mkfile("/proj/src/b.rs", Some("rs")),
+            mkfile("/proj/src/c.rs", Some("rs")),
+            mkfile("/proj/docs/readme.md", Some("md")),
+            mkfile("/proj/docs/guide.md", Some("md")),
+            mkfile("/proj/docs/notes.md", Some("md")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads.len(), 2);
+        let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
+        // Phase B: labels usan los últimos 2 componentes del path para
+        // desambiguar (proj/src vs proj/docs en lugar de src vs docs).
+        assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
+        assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
+    }
+
+    #[test]
+    fn small_groups_not_promoted() {
+        let files = vec![
+            mkfile("/proj/single.txt", Some("txt")),
+            mkfile("/proj/sub/a.txt", Some("txt")),
+            mkfile("/proj/sub/b.txt", Some("txt")),
+            mkfile("/proj/sub/c.txt", Some("txt")),
+        ];
+        // min=3 → /proj/single solo no se promueve, /proj/sub sí.
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads.len(), 1);
+        assert_eq!(monads[0].label, "proj/sub");
+    }
+
+    #[test]
+    fn label_from_root_only_one_component() {
+        // Un solo componente normal en el path → no hay "padre" útil.
+        let p = std::path::Path::new("/onlyone");
+        assert_eq!(label_from_path(p), "onlyone");
+    }
+
+    #[test]
+    fn label_from_deep_path_takes_last_two() {
+        let p = std::path::Path::new("/a/b/c/d/e");
+        assert_eq!(label_from_path(p), "d/e");
+    }
+
+    #[test]
+    fn lens_picked_by_dominant_extension() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.rs", Some("rs")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].dominant_lens, Lens::Code);
+
+        let files = vec![
+            mkfile("/y/1.png", Some("png")),
+            mkfile("/y/2.png", Some("png")),
+            mkfile("/y/3.png", Some("png")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].dominant_lens, Lens::Gallery);
+    }
+
+    #[test]
+    fn entropy_zero_for_homogeneous() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.rs", Some("rs")),
+        ];
+        let monads = by_directory(&files, 3);
+        assert_eq!(monads[0].entropy, 0.0);
+    }
+
+    #[test]
+    fn entropy_high_for_diverse() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.md", Some("md")),
+            mkfile("/x/c.json", Some("json")),
+            mkfile("/x/d.png", Some("png")),
+        ];
+        let monads = by_directory(&files, 3);
+        // 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
+        assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
+    }
+
+    #[test]
+    fn top_extensions_orders_by_freq_then_alpha() {
+        let files = vec![
+            mkfile("/x/a.rs", Some("rs")),
+            mkfile("/x/b.rs", Some("rs")),
+            mkfile("/x/c.md", Some("md")),
+            mkfile("/x/d.py", Some("py")),
+        ];
+        let refs: Vec<&FileEntry> = files.iter().collect();
+        let top = top_extensions(&refs, 3);
+        assert_eq!(top, vec!["rs", "md", "py"]);
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-core/src/db.rs b/02_ruway/chasqui/chasqui-core/src/db.rs
new file mode 100644
index 0000000..6674015
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/src/db.rs
@@ -0,0 +1,313 @@
+//! DB de Mónadas y archivos. Backend dual:
+//!
+//! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
+//! - **Persistencia** (opcional): sled-backed write-through. Si se abre
+//!   con `MonadDb::open(path)`, cada `insert_*` escribe a sled además
+//!   de la cache. Reads siempre vienen de la cache.
+//!
+//! Wire format: JSON via serde_json. Los manifestos son chicos y
+//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre
+//! postcard en debuggability.
+
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use chasqui_card::{FileEntry, FileId, MonadId, MonadManifest};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum MonadDbError {
+    #[error("sled: {0}")]
+    Sled(#[from] sled::Error),
+    #[error("JSON: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("ULID inválido en clave: {0}")]
+    BadKey(String),
+}
+
+const TREE_FILES: &str = "files";
+const TREE_MONADS: &str = "monads";
+
+/// Store de Mónadas + archivos. Cache en memoria + persistencia
+/// opcional sled.
+pub struct MonadDb {
+    files: BTreeMap<FileId, FileEntry>,
+    monads: BTreeMap<MonadId, MonadManifest>,
+    persistence: Option<sled::Db>,
+}
+
+impl Default for MonadDb {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl MonadDb {
+    /// Store en memoria pura (sin persistencia). El estado se pierde al salir.
+    pub fn new() -> Self {
+        Self {
+            files: BTreeMap::new(),
+            monads: BTreeMap::new(),
+            persistence: None,
+        }
+    }
+
+    /// Abre (o crea) un store sled-backed en `path`. Carga el contenido
+    /// existente a la cache antes de devolver.
+    pub fn open(path: impl AsRef<Path>) -> Result<Self, MonadDbError> {
+        let db = sled::open(path)?;
+        let mut files = BTreeMap::new();
+        let mut monads = BTreeMap::new();
+
+        let files_tree = db.open_tree(TREE_FILES)?;
+        for kv in files_tree.iter() {
+            let (k, v) = kv?;
+            let id = decode_key(&k)?;
+            let entry: FileEntry = serde_json::from_slice(&v)?;
+            files.insert(id, entry);
+        }
+        let monads_tree = db.open_tree(TREE_MONADS)?;
+        for kv in monads_tree.iter() {
+            let (k, v) = kv?;
+            let id = decode_key(&k)?;
+            let monad: MonadManifest = serde_json::from_slice(&v)?;
+            monads.insert(id, monad);
+        }
+
+        Ok(Self {
+            files,
+            monads,
+            persistence: Some(db),
+        })
+    }
+
+    /// `true` si tiene backend persistente.
+    pub fn is_persistent(&self) -> bool {
+        self.persistence.is_some()
+    }
+
+    // ---- Files ----
+
+    pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
+        if let Some(db) = &self.persistence {
+            // Write-through: si falla el persist, lo logeamos pero la
+            // memoria queda actualizada. Filosofía: cache nunca miente
+            // sobre el último estado conocido en este proceso.
+            if let Err(e) = persist_file(db, &file) {
+                eprintln!("[MonadDb] persist file falló: {e}");
+            }
+        }
+        self.files.insert(file.id, file)
+    }
+
+    pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
+        for f in files {
+            self.insert_file(f);
+        }
+    }
+
+    pub fn file(&self, id: FileId) -> Option<&FileEntry> {
+        self.files.get(&id)
+    }
+
+    pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
+        self.files.values()
+    }
+
+    pub fn file_count(&self) -> usize {
+        self.files.len()
+    }
+
+    // ---- Monads ----
+
+    pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
+        if let Some(db) = &self.persistence {
+            if let Err(e) = persist_monad(db, &monad) {
+                eprintln!("[MonadDb] persist monad falló: {e}");
+            }
+        }
+        self.monads.insert(monad.id, monad)
+    }
+
+    pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
+        // Si hay persistencia, limpiar tree antes de insertar.
+        if let Some(db) = &self.persistence {
+            if let Ok(tree) = db.open_tree(TREE_MONADS) {
+                let _ = tree.clear();
+            }
+        }
+        self.monads.clear();
+        for m in monads {
+            self.insert_monad(m);
+        }
+    }
+
+    pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
+        self.monads.get(&id)
+    }
+
+    pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
+        self.monads.values()
+    }
+
+    pub fn monad_count(&self) -> usize {
+        self.monads.len()
+    }
+
+    /// Resuelve los archivos miembros de una Mónada como referencias.
+    /// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
+    pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
+        match self.monads.get(&monad_id) {
+            Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
+            None => Vec::new(),
+        }
+    }
+}
+
+fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> {
+    let tree = db.open_tree(TREE_FILES)?;
+    let key = f.id.to_string();
+    let val = serde_json::to_vec(f)?;
+    tree.insert(key.as_bytes(), val)?;
+    Ok(())
+}
+
+fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> {
+    let tree = db.open_tree(TREE_MONADS)?;
+    let key = m.id.to_string();
+    let val = serde_json::to_vec(m)?;
+    tree.insert(key.as_bytes(), val)?;
+    Ok(())
+}
+
+fn decode_key(k: &[u8]) -> Result<ulid::Ulid, MonadDbError> {
+    let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?;
+    ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chasqui_card::Lens;
+    use ulid::Ulid;
+
+    fn mk_file(path: &str) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: std::path::PathBuf::from(path),
+            content_hash: None,
+            size: 100,
+            mtime_ms: 0,
+            extension: Some("rs".into()),
+        }
+    }
+
+    #[test]
+    fn ingest_and_lookup() {
+        let mut db = MonadDb::new();
+        let f1 = mk_file("/a/x.rs");
+        let f2 = mk_file("/a/y.rs");
+        let id1 = f1.id;
+        db.ingest_files(vec![f1, f2]);
+        assert_eq!(db.file_count(), 2);
+        assert!(db.file(id1).is_some());
+        assert!(!db.is_persistent());
+    }
+
+    #[test]
+    fn resolve_members_filters_missing() {
+        let mut db = MonadDb::new();
+        let f1 = mk_file("/x/a.rs");
+        let id1 = f1.id;
+        db.insert_file(f1);
+
+        let mut m = MonadManifest::new("test");
+        m.members.insert(id1);
+        m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
+        m.dominant_lens = Lens::Code;
+        m.touch();
+
+        let mid = m.id;
+        db.insert_monad(m);
+
+        let resolved = db.resolve_members(mid);
+        assert_eq!(resolved.len(), 1);
+        assert_eq!(resolved[0].id, id1);
+    }
+
+    #[test]
+    fn replace_monads_clears_old() {
+        let mut db = MonadDb::new();
+        let mut m1 = MonadManifest::new("a");
+        m1.members.insert(FileId::from(Ulid::new()));
+        m1.touch();
+        db.insert_monad(m1);
+        assert_eq!(db.monad_count(), 1);
+
+        let mut m2 = MonadManifest::new("b");
+        m2.members.insert(FileId::from(Ulid::new()));
+        m2.touch();
+        db.replace_monads(vec![m2]);
+        assert_eq!(db.monad_count(), 1);
+        assert!(db.monads().next().unwrap().label == "b");
+    }
+
+    #[test]
+    fn persistence_roundtrip() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dbpath = tmp.path().join("monads.sled");
+
+        // Escribimos algunos datos
+        {
+            let mut db = MonadDb::open(&dbpath).expect("open");
+            assert!(db.is_persistent());
+            let f = mk_file("/persist/a.rs");
+            let fid = f.id;
+            db.insert_file(f);
+
+            let mut m = MonadManifest::new("persist-test");
+            m.members.insert(fid);
+            m.dominant_lens = Lens::Code;
+            m.touch();
+            db.insert_monad(m);
+        }
+
+        // Reabrimos y verificamos que están
+        let db = MonadDb::open(&dbpath).expect("reopen");
+        assert_eq!(db.file_count(), 1);
+        assert_eq!(db.monad_count(), 1);
+        let m = db.monads().next().unwrap();
+        assert_eq!(m.label, "persist-test");
+        assert_eq!(m.cardinality, 1);
+    }
+
+    #[test]
+    fn replace_monads_purges_persistent_tree() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dbpath = tmp.path().join("replace.sled");
+
+        {
+            let mut db = MonadDb::open(&dbpath).unwrap();
+            let mut m1 = MonadManifest::new("old");
+            m1.members.insert(FileId::from(Ulid::new()));
+            m1.touch();
+            db.insert_monad(m1);
+        }
+
+        // Reabrir, replace, verificar
+        {
+            let mut db = MonadDb::open(&dbpath).unwrap();
+            assert_eq!(db.monad_count(), 1);
+            let mut m2 = MonadManifest::new("new");
+            m2.members.insert(FileId::from(Ulid::new()));
+            m2.touch();
+            db.replace_monads(vec![m2]);
+            assert_eq!(db.monad_count(), 1);
+        }
+
+        // Tercera apertura: sólo "new" sobrevive
+        let db = MonadDb::open(&dbpath).unwrap();
+        assert_eq!(db.monad_count(), 1);
+        assert_eq!(db.monads().next().unwrap().label, "new");
+    }
+}
diff --git a/02_ruway/chasqui/chasqui-core/src/embed.rs b/02_ruway/chasqui/chasqui-core/src/embed.rs
new file mode 100644
index 0000000..4113d5e
--- /dev/null
+++ b/02_ruway/chasqui/chasqui-core/src/embed.rs
@@ -0,0 +1,298 @@
+//! Pseudo-embeddings de archivos: vectores deterministas derivados de
+//! metadatos (sin LLM).
+//!
+//! Implementan el "imán semántico" matemático que el diseño de Kairos
+//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
+//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
+//! cerca (cosine similarity).
+//!
+//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
+//! - Bootstrapping sin Nous corriendo.
+//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
+//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
+//!   muy juntos en el espacio vectorial).
+//!
+//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
+//!
+//! - dims  0..8:  `blake3(extension)`     → identidad de tipo
+//! - dims  8..16: `blake3(parent_dir)`    → identidad de contenedor
+//! - dims 16..24: `blake3(file_stem)`     → identidad léxica del archivo
+//! - dims 24..28: tamaño (log scale + flags binarios)
+//! - dims 28..32: mtime (escala día + features cíclicas)
+//!
+//! ## Propiedades empíricas
+//!
+//! - Mismo dir + misma ext       → similitud > 0.7 (alta cohesión).
+//! - Mismo dir + ext distinta    → similitud ~ 0.5.
+//! - Dirs distintos + misma ext  → similitud ~ 0.5.
+//! - Sin parecido                → similitud < 0.3.
+
+use chasqui_card::{FileEntry, MonadId, MonadManifest};
+
+/// Dimensión del vector embedding.
+pub const EMBED_DIM: usize = 32;
+
+/// Identificador del modelo que produce este embedding. Se usa para
+/// taggear `MonadManifest.centroid_model`: los consumidores comparan
+/// este string contra el suyo antes de hacer cosine similarity.
+/// Mezclar centroides de distinto MODEL_ID corrompe scores
+/// silenciosamente (dimensiones distintas, semántica distinta).
+pub const MODEL_ID: &str = "chasqui-pseudo-32d";
+
+/// Computa el embedding de un archivo. Determinístico: misma input
+/// → mismo vector. El vector queda L2-normalizado.
+pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
+    let mut v = [0.0f32; EMBED_DIM];
+
+    // dims 0..8: extension hash
+    fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
+
+    // dims 8..16: parent dir name hash
+    let parent = file
+        .path
+        .parent()
+        .and_then(|p| p.file_name())
+        .and_then(|n| n.to_str())
+        .unwrap_or("");
+    fill_from_hash(&mut v[8..16], parent);
+
+    // dims 16..24: file stem hash (sin extensión)
+    let stem = file
+        .path
+        .file_stem()
+        .and_then(|n| n.to_str())
+        .unwrap_or("");
+    fill_from_hash(&mut v[16..24], stem);
+
+    // dims 24..28: tamaño (centrado en 0 para que dot products entre
+    // archivos de tamaño diferente sumen 0 en expectativa).
+    let log_size = (file.size.max(1) as f32).log10();
+    v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
+    v[25] = (log_size.fract() - 0.5) * 2.0;
+    v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
+    v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
+
+    // dims 28..32: mtime — escala día + cíclicas (centradas).
+    let day = file.mtime_ms / (86_400 * 1000);
+    v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
+    v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
+    v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
+    v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
+
+    normalize(&mut v);
+    v
+}
+
+/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
+/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
+/// así dos vectores hash distintos (de strings no relacionados) tendrían
+/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
+/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
+/// que cosine similarity sea una métrica útil de afinidad.
+fn fill_from_hash(out: &mut [f32], input: &str) {
+    let h = blake3::hash(input.as_bytes());
+    let bytes = h.as_bytes();
+    for (i, slot) in out.iter_mut().enumerate() {
+        *slot = (bytes[i] as f32 - 127.5) / 127.5;
+    }
+}
+
+/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
+fn normalize(v: &mut [f32]) {
+    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+    if norm > 0.0 {
+        for x in v.iter_mut() {
+            *x /= norm;
+        }
+    }
+}
+
+/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
+/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
+/// no coinciden, devuelve 0.
+pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
+    if a.len() != b.len() || a.is_empty() {
+        return 0.0;
+    }
+    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
+}
+
+/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
+/// L2-normalización. El resultado es un vector unidad apto para
+/// comparar con miembros nuevos vía cosine similarity.
+pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
+    if vectors.is_empty() {
+        return Vec::new();
+    }
+    let dim = vectors[0].len();
+    let mut c = vec![0.0f32; dim];
+    for v in vectors {
+        if v.len() != dim {
+            continue;
+        }
+        for (i, x) in v.iter().enumerate() {
+            c[i] += x;
+        }
+    }
+    let n = vectors.len() as f32;
+    for x in c.iter_mut() {
+        *x /= n;
+    }
+    normalize(&mut c);
+    c
+}
+
+/// Cohesión interna: media de cosine similarity de cada miembro contra
+/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
+pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
+    if member_vectors.is_empty() || centroid.is_empty() {
+        return 0.0;
+    }
+    let sum: f32 = member_vectors
+        .iter()
+        .map(|v| cosine_similarity(centroid, v))
+        .sum();
+    sum / member_vectors.len() as f32
+}
+
+/// Score de atracción de un archivo nuevo a una Mónada existente:
+/// cosine similarity de su embedding contra el centroide de la Mónada.
+/// Mayor score = mayor afinidad.
+pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
+    if monad.centroid.is_empty() {
+        return 0.0;
+    }
+    cosine_similarity(file_vec, &monad.centroid)
+}
+
+/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
+/// `(MonadId, score)` o `None` si ninguna tiene centroide.
+pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
+where
+    I: IntoIterator<Item = &'a MonadManifest>,
+{
+    monads
+        .into_iter()
+        .filter(|m| !m.centroid.is_empty())
+        .map(|m| (m.id, attraction_score(file_vec, m)))
+        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
+}
+
+/// Umbral por defecto para "se pega": si el score es ≥ esto, el
+/// archivo se asigna automáticamente. Ajustable por el caller.
+pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chasqui_card::FileId;
+    use std::path::PathBuf;
+    use ulid::Ulid;
+
+    fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
+        FileEntry {
+            id: FileId::from(Ulid::new()),
+            path: PathBuf::from(path),
+            content_hash: None,
+            size,
+            mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
+            extension: ext.map(String::from),
+        }
+    }
+
+    #[test]
+    fn embed_is_deterministic() {
+        let a = mk("/x/foo.rs", Some("rs"), 1024);
+        let b = mk("/x/foo.rs", Some("rs"), 1024);
+        let va = embed(&a);
+        let vb = embed(&b);
+        // Mismos metadatos → mismo vector (los IDs no entran al embedding).
+        assert_eq!(va, vb);
+    }
+
+    #[test]
+    fn embed_is_unit_normalized() {
+        let f = mk("/x/foo.rs", Some("rs"), 1024);
+        let v = embed(&f);
+        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
+    }
+
+    #[test]
+    fn same_dir_same_ext_high_similarity() {
+        let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
+        let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
+        let sim = cosine_similarity(&a, &b);
+        assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
+    }
+
+    #[test]
+    fn unrelated_files_low_similarity() {
+        let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
+        let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
+        let sim = cosine_similarity(&a, &b);
+        assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
+    }
+
+    #[test]
+    fn centroid_is_unit_and_close_to_members() {
+        let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
+        let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
+        let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
+        let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
+
+        // Norma unitaria.
+        let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
+        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
+
+        // Cohesión alta porque los miembros son similares.
+        let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
+        assert!(cohesion > 0.9, "cohesion={cohesion}");
+    }
+
+    #[test]
+    fn attraction_picks_correct_monad() {
+        // Construimos dos Mónadas: una de Rust, otra de imágenes.
+        let rust_files = vec![
+            embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
+            embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
+        ];
+        let img_files = vec![
+            embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
+            embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
+        ];
+
+        let mut rust_monad = MonadManifest::new("rust");
+        rust_monad.members.insert(FileId::from(Ulid::new()));
+        rust_monad.touch();
+        rust_monad.centroid = centroid(&rust_files);
+
+        let mut img_monad = MonadManifest::new("photos");
+        img_monad.members.insert(FileId::from(Ulid::new()));
+        img_monad.touch();
+        img_monad.centroid = centroid(&img_files);
+
+        // Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
+        let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
+        let (best_id, _score) = best_attraction(&ne

El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.