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>
Delta semántico · 592
02_ruway/chasqui/card-admin/examples/brahman-status.rs
- nueva fn main
02_ruway/chasqui/card-admin/src/client.rs
- nueva enum AdminError pub
- nueva fn query pub
- nueva fn query_blocking pub
02_ruway/chasqui/card-admin/src/lib.rs
- nueva def ADMIN_VERSION pub
- nueva mod client pub
- nueva mod server pub
- nueva mod snapshot pub
- nueva mod transport pub
02_ruway/chasqui/card-admin/src/server.rs
- nueva struct AdminConfig pub
- nueva struct AdminServer pub
- nueva fn bind pub
- nueva fn build_snapshot
- nueva fn drop
- nueva fn handle_conn
- nueva fn run pub
- nueva fn socket_path pub
02_ruway/chasqui/card-admin/src/snapshot.rs
- nueva struct StatusSnapshot pub
02_ruway/chasqui/card-admin/src/transport.rs
- nueva def SOCKET_ENV pub
- nueva def SOCKET_NAME pub
- nueva fn default_socket_path pub
02_ruway/chasqui/card-handshake/examples/probe.rs
- nueva fn main
02_ruway/chasqui/card-handshake/examples/subscriber.rs
- nueva fn main
02_ruway/chasqui/card-handshake/src/client.rs
- nueva struct Client pub
- nueva enum ClientError pub
- nueva fn await_event pub
- nueva fn connect pub
- nueva fn connect_inner
- nueva fn connect_with pub
- nueva fn connect_with_stream pub
- nueva fn connect_with_stream_signed pub
- nueva fn connect_with_stream_signed_with_cert pub
- nueva fn farewell pub
- nueva fn list_matches pub
- nueva fn list_sessions pub
- nueva fn ping pub
- nueva fn server_info pub
- nueva fn session pub
- nueva fn take_event pub
02_ruway/chasqui/card-handshake/src/codec.rs
- nueva def MAX_FRAME_BYTES pub
- nueva fn frame_roundtrip
- nueva fn read_frame pub
- nueva mod tests
- nueva fn write_frame pub
02_ruway/chasqui/card-handshake/src/identity.rs
- nueva enum CertError pub
- nueva def DEFAULT_SESSION_TTL pub
- nueva struct Identity pub
- nueva def SESSION_CERT_VERSION pub
- nueva struct SessionCert pub
- nueva fn cert_with_zero_ttl_is_expired
- nueva fn fmt
- nueva fn from_arc pub
- nueva fn from_keypair pub
- nueva fn issue_and_verify_cert
- nueva fn issue_session_cert pub
- nueva fn master_peer_id pub
- nueva fn now_unix_ms
- nueva fn rotated_session_with_same_master_yields_same_master_peer_id
- nueva fn sign_payload
- nueva fn tampered_expires_at_rejected
- nueva fn tampered_signature_rejected
- nueva mod tests
- nueva fn unknown_version_rejected
- nueva fn verify pub
- nueva fn verify_against_session pub
- nueva fn verify_against_session_admits_matching
- nueva fn verify_against_session_rejects_mismatch
02_ruway/chasqui/card-handshake/src/lib.rs
- nueva def HANDSHAKE_VERSION pub
- nueva mod client pub
- nueva mod codec pub
- nueva mod identity pub
- nueva mod messages pub
- nueva mod network pub
- nueva mod peer_policy pub
- nueva mod server pub
- nueva mod signature pub
- nueva mod transport pub
02_ruway/chasqui/card-handshake/src/messages.rs
- nueva struct Farewell pub
- nueva enum Frame pub
- nueva enum HandshakeError pub
- nueva struct Hello pub
- nueva struct HelloAck pub
- nueva struct HelloSignature pub
- nueva struct ListMatches pub
- nueva struct ListSessions pub
- nueva struct MatchEvent pub
- nueva enum MatchEventKind pub
- nueva struct MatchList pub
- nueva struct Ping pub
- nueva struct Pong pub
- nueva struct SessionEntry pub
- nueva type SessionId pub
- nueva struct SessionList pub
02_ruway/chasqui/card-handshake/src/network.rs
- nueva def BRAHMAN_HANDSHAKE_PROTOCOL pub
- nueva def FLOW_KEY_PREFIX
- nueva type LibP2pHandshakeStream pub
- nueva enum NetworkError pub
- nueva fn announce_outputs pub
- nueva fn canonicalize_type
- nueva fn connect_libp2p pub
- nueva fn connect_libp2p_with_cert pub
- nueva fn find_remote_providers pub
- nueva fn flow_dht_key pub
- nueva fn handle_libp2p_session
- nueva fn run_libp2p_accept_loop pub
- nueva fn withdraw_outputs pub
02_ruway/chasqui/card-handshake/src/peer_policy.rs
- nueva def DEBOUNCE_MS
- nueva enum Decision pub
- nueva struct PeerPolicy pub
- nueva enum PolicyError pub
- nueva struct PolicyInner
- nueva struct PolicyPaths
- nueva fn allow_only_admits_listed
- nueva fn attach_to_net pub
- nueva fn deny_overrides_allow
- nueva fn deny_overrides_open
- nueva fn evaluate pub
- nueva fn fmt
- nueva fn fresh_peer
- nueva fn from_files pub
- nueva fn from_files_only_deny
- nueva fn from_files_with_both_lists
- nueva fn from_sets pub
- nueva fn invalid_file_rejected_at_load
- nueva fn is_admitted pub
- nueva fn open pub
- nueva fn open_admits_anyone
- nueva fn parse_peer_set
- nueva fn reason pub
- nueva fn reload pub
- nueva fn reload_failure_preserves_previous_state
- nueva fn reload_picks_up_changes
- nueva fn run_watcher
- nueva fn sizes pub
- nueva fn spawn_watcher pub
- nueva fn sync_deny_to_swarm
- nueva mod tests
- nueva fn watcher_reloads_on_file_change
02_ruway/chasqui/card-handshake/src/server.rs
- nueva type LastMatches
- nueva def PUSH_CHANNEL_CAPACITY
- nueva struct Server pub
- nueva struct ServerConfig pub
- nueva struct Session pub
- nueva type SessionRegistry pub
- nueva type SessionTxTable
- nueva type SharedBroker pub
- nueva fn accept_one pub
- nueva fn bind pub
- nueva fn broadcast_match_diffs
- nueva fn build_session_list
- nueva fn cleanup
- nueva fn do_handshake
- nueva fn drop
- nueva fn fmt
- nueva fn handle pub
- nueva fn handle_inbound_frame
- nueva fn now_ms
- nueva fn register_session
- nueva fn run pub
- nueva fn run_post_handshake
- nueva fn session_from_libp2p_stream pub
- nueva fn session_from_stream pub
- nueva fn session_from_stream_inner
- nueva fn sessions pub
- nueva fn socket_path pub
- nueva fn validate_hello
02_ruway/chasqui/card-handshake/src/signature.rs
- nueva def SIGNATURE_VERSION pub
- nueva enum SignatureError pub
- nueva fn payload_bytes
- nueva fn sample_card
- nueva fn sign_hello pub
- nueva fn sign_then_verify_roundtrip
- nueva mod tests
- nueva fn verify_hello pub
- nueva fn verify_rejects_corrupted_signature
- nueva fn verify_rejects_tampered_card
- nueva fn verify_rejects_wrong_peer
02_ruway/chasqui/card-handshake/src/transport.rs
- nueva def SOCKET_ENV pub
- nueva def SOCKET_NAME pub
- nueva fn default_socket_path pub
- nueva fn env_override_wins
- nueva mod tests
02_ruway/chasqui/card-handshake/tests/handshake.rs
- nueva fn broker_matches_two_live_modules
- nueva fn broker_registers_and_unregisters_with_session
- nueva fn card_with_flows
- nueva fn flow
- nueva fn full_handshake_roundtrip
- nueva fn list_sessions_returns_currently_registered
- nueva fn match_event_pushed_on_producer_arrival
- nueva fn ping_before_hello_rejected
- nueva fn rejects_invalid_card_client_side
- nueva fn sample_card
- nueva fn server_rejects_protocol_mismatch
- nueva fn sock_path
- nueva fn wait_for_broker_len
02_ruway/chasqui/card-handshake/tests/network_discovery.rs
- nueva fn dht_discovery_finds_remote_provider
- nueva fn dht_discovery_negative_unknown_flow
- nueva fn dht_discovery_withdraws_on_session_cleanup
- nueva fn provider_card
02_ruway/chasqui/card-handshake/tests/network_libp2p.rs
- nueva fn identity_cert_allows_session_rotation_without_policy_change
- nueva fn libp2p_handshake_allowlist_admits_listed_rejects_others
- nueva fn libp2p_handshake_denylist_blocks_listed_peer
- nueva fn libp2p_handshake_rejects_mismatched_signing_key
- nueva fn libp2p_handshake_roundtrip
- nueva fn sample_card
- nueva fn swarm_level_deny_blocks_before_noise
02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs
- nueva fn main
02_ruway/chasqui/card-sidecar/examples/presence.rs
- nueva fn main
02_ruway/chasqui/card-sidecar/src/discovery.rs
- nueva enum ConsumerError pub
- nueva enum ProviderLocation pub
- nueva fn await_provider pub
- nueva fn await_provider_blocking pub
- nueva fn build_consumer_card pub
- nueva fn builder_assigns_distinct_ids_per_call
- nueva fn builder_sets_input_flow_with_primitive_type
- nueva fn consume_remote pub
- nueva fn describe_falls_back_when_no_input_flow
- nueva fn describe_first_input
- nueva fn describe_formats_wit_type
- nueva fn list_matches pub
- nueva fn list_matches_blocking pub
- nueva fn list_sessions pub
- nueva fn list_sessions_blocking pub
- nueva fn resolve_provider pub
- nueva mod tests
02_ruway/chasqui/card-sidecar/src/lib.rs
- nueva def DEFAULT_PING_INTERVAL pub
- nueva struct SidecarConfig pub
- nueva struct SidecarPool pub
- nueva fn default
- nueva mod discovery pub
- nueva fn drop_session pub
- nueva fn live_sessions pub
- nueva fn new pub
- nueva fn new pub
- nueva fn run_client pub
- nueva fn run_thread
- nueva fn spawn pub
- nueva fn spawn pub
- nueva fn spawn_conscious pub
- nueva fn spawn_conscious pub
- nueva fn spawn_with_config pub
- nueva fn spawn_with_handle pub
- nueva fn with_wit pub
02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs
02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs
- nueva struct Explorer
- nueva type MatchKey
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva def POLL_INTERVAL
- nueva def PROBE_TIMEOUT
- nueva enum ProbeState
- nueva def TIMELINE_CAP
- nueva struct TimelineEntry
- nueva fn app_menu
- nueva fn diff_matches
- nueva fn diff_matches_emits_lost_when_match_disappears
- nueva fn diff_matches_first_snapshot_marks_everything_available
- nueva fn diff_matches_into_timeline
- nueva fn diff_matches_no_change_emits_nothing
- nueva fn format_timeline_entry
- nueva fn handle_menu_command
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn menubar_spec
- nueva fn on_key
- nueva fn pending_is_default_state_at_boot
- nueva fn poll_and_probe_constants_are_sane
- nueva fn push_timeline
- nueva fn run_probe
- nueva fn state_card_params
- nueva fn synth_match
- nueva mod tests
- nueva fn title
- nueva fn update
- nueva fn view
- nueva fn view_overlay
- nueva fn viewport_of
02_ruway/chasqui/chasqui-broker/src/lib.rs
- nueva struct Broker pub
- nueva struct BrokerConfig pub
- nueva struct BrokeredCard pub
- nueva struct Endpoint pub
- nueva struct Match pub
- nueva enum MatchStrategy pub
- nueva type SessionId pub
- nueva fn all_matches pub
- nueva fn all_matches_lists_pairs
- nueva fn card
- nueva fn cards pub
- nueva fn context_bias
- nueva fn context_pin_to_overrides_static_pin
- nueva fn context_priority_offset_lifts_producer_above_alphabetic_winner
- nueva fn effective_priority
- nueva fn exact_match
- nueva fn exact_match_same_typeref
- nueva fn exact_strategy_rejects_interface_mismatch
- nueva fn exact_then_structural_prefers_exact
- nueva fn find_producer_for pub
- nueva fn flow
- nueva fn from_card
- nueva fn is_empty pub
- nueva fn label_alpha_breaks_priority_ties
- nueva fn len pub
- nueva fn make_match
- nueva fn new pub
- nueva fn no_self_loops
- nueva fn pin_to_overrides_type_search
- nueva fn pin_to_unresolvable_falls_back_to_type_match
- nueva fn prim
- nueva fn priority_breaks_ties
- nueva fn priority_offset_clamps_to_critical
- nueva fn priority_value
- nueva fn register pub
- nueva fn sessions pub
- nueva fn structural_ignores_interface
- nueva fn structural_match
- nueva mod tests
- nueva fn types_match
- nueva fn unknown_context_no_op
- nueva fn unregister pub
- nueva fn unregister_removes_producer
- nueva fn wit
02_ruway/chasqui/chasqui-card/src/lib.rs
- nueva struct FileEntry pub
- nueva type FileId pub
- nueva enum Lens pub
- nueva def MONAD_SCHEMA_VERSION pub
- nueva enum MonadError pub
- nueva type MonadId pub
- nueva struct MonadManifest pub
- nueva fn cardinality_mismatch_caught
- nueva fn empty_label_rejected
- nueva fn empty_members_rejected
- nueva fn entropy_out_of_range_rejected
- nueva fn from_json pub
- nueva fn json_roundtrip
- nueva fn new pub
- nueva fn projects_to_brahman_card
- nueva mod query pub
- nueva mod tests
- nueva fn to_brahman_card pub
- nueva fn to_json_pretty pub
- nueva fn touch pub
- nueva fn validate pub
- nueva fn validates_minimal
02_ruway/chasqui/chasqui-card/src/query.rs
- nueva struct EngineInfo pub
- nueva struct ErrorResponse pub
- nueva def FLOW_MONAD_LIST pub
- nueva def FLOW_TYPE_NAME pub
- nueva struct FileView pub
- nueva struct ListMonadsResponse pub
- nueva struct MonadView pub
- nueva enum QueryError pub
- nueva enum QueryRequest pub
- nueva struct ResolveMonadResponse pub
- nueva def SOCKET_ENV pub
- nueva def SOCKET_NAME pub
- nueva mod client pub
- nueva fn default_socket_path pub
- nueva fn from_entry pub
- nueva fn from_manifest pub
- nueva fn list_monads pub
- nueva fn request
- nueva fn request_roundtrips_json_with_tag
- nueva fn resolve_monad pub
- nueva fn resolve_monad_request_roundtrips_with_id
- nueva fn resolve_monad_response_roundtrip
- nueva fn response_roundtrip_preserves_view
- nueva mod tests
- nueva mod transport pub
- nueva fn view_is_slim_no_centroid_no_members
02_ruway/chasqui/chasqui-core/src/bin/nouser.rs
- nueva type Cmd
- nueva def WATCHER_DEBOUNCE_MS
- nueva fn build_engine_card
- nueva fn cmd_attract
- nueva fn cmd_daemon
- nueva fn cmd_json
- nueva fn cmd_scan
- nueva fn cmd_show
- nueva fn embed_via
- nueva fn main
- nueva fn min_files
- nueva fn open_db
- nueva fn print_usage
- nueva fn process_change_batch
- nueva fn remote_embed
- nueva fn require_dir
- nueva fn run_scan
- nueva fn spawn_fs_watcher
02_ruway/chasqui/chasqui-core/src/cluster.rs
- nueva def DEFAULT_MIN_FILES_PER_MONAD pub
- nueva fn build_monad
- nueva fn build_summary
- nueva fn by_directory pub
- nueva fn by_directory_hydrated pub
- nueva fn discern_lens
- nueva fn entropy_high_for_diverse
- nueva fn entropy_zero_for_homogeneous
- nueva fn groups_by_parent_directory
- nueva fn label_from_deep_path_takes_last_two
- nueva fn label_from_path
- nueva fn label_from_root_only_one_component
- nueva fn lens_picked_by_dominant_extension
- nueva fn mkfile
- nueva fn pick_lens
- nueva fn shannon_entropy_normalized
- nueva fn small_groups_not_promoted
- nueva mod tests
- nueva fn top_extensions
- nueva fn top_extensions_orders_by_freq_then_alpha
02_ruway/chasqui/chasqui-core/src/db.rs
- nueva struct MonadDb pub
- nueva enum MonadDbError pub
- nueva def TREE_FILES
- nueva def TREE_MONADS
- nueva fn decode_key
- nueva fn default
- nueva fn file pub
- nueva fn file_count pub
- nueva fn files pub
- nueva fn ingest_and_lookup
- nueva fn ingest_files pub
- nueva fn insert_file pub
- nueva fn insert_monad pub
- nueva fn is_persistent pub
- nueva fn mk_file
- nueva fn monad pub
- nueva fn monad_count pub
- nueva fn monads pub
- nueva fn new pub
- nueva fn open pub
- nueva fn persist_file
- nueva fn persist_monad
- nueva fn persistence_roundtrip
- nueva fn replace_monads pub
- nueva fn replace_monads_clears_old
- nueva fn replace_monads_purges_persistent_tree
- nueva fn resolve_members pub
- nueva fn resolve_members_filters_missing
- nueva mod tests
02_ruway/chasqui/chasqui-core/src/embed.rs
- nueva def DEFAULT_ATTRACTION_THRESHOLD pub
- nueva def EMBED_DIM pub
- nueva def MODEL_ID pub
- nueva fn attraction_picks_correct_monad
- nueva fn attraction_score pub
- nueva fn best_attraction pub
- nueva fn centroid pub
- nueva fn centroid_is_unit_and_close_to_members
- nueva fn cohesion pub
- nueva fn cosine_similarity pub
- nueva fn embed pub
- nueva fn embed_is_deterministic
- nueva fn embed_is_unit_normalized
- nueva fn empty_centroid_skipped_in_attraction
- nueva fn fill_from_hash
- nueva fn mk
- nueva fn normalize
- nueva fn same_dir_same_ext_high_similarity
- nueva mod tests
- nueva fn unrelated_files_low_similarity
02_ruway/chasqui/chasqui-core/src/engine_socket.rs
- nueva struct ListenerConfig pub
- nueva fn encode_error
- nueva fn fresh_socket_path
- nueva fn handle_conn
- nueva fn handle_list_monads
- nueva fn handle_resolve_monad
- nueva fn invalid_request_returns_error_response
- nueva fn list_monads_returns_views
- nueva fn list_monads_roundtrip_empty
- nueva fn resolve_monad_returns_member_files
- nueva fn spawn_listener pub
- nueva mod tests
02_ruway/chasqui/chasqui-core/src/lib.rs
- nueva mod cluster pub
- nueva mod db pub
- nueva mod embed pub
- nueva mod engine_socket pub
- nueva mod scanner pub
02_ruway/chasqui/chasqui-core/src/scanner.rs
- nueva struct ScanConfig pub
- nueva enum ScanError pub
- nueva fn default
- nueva fn is_hidden
- nueva fn max_depth_limits
- nueva fn missing_root_errors
- nueva fn scan_directory pub
- nueva fn scans_basic_tree
- nueva fn skips_hidden_by_default
- nueva mod tests
- nueva fn write
02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs
- nueva def DISCOVERY_TIMEOUT
- nueva struct Explorer
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva def QUERY_TIMEOUT
- nueva def REFRESH_INTERVAL
- nueva enum TickOutcome
- nueva fn app_menu
- nueva fn discover_via_broker
- nueva fn engine_card
- nueva fn handle_menu_command
- nueva fn init
- nueva fn initial_size
- nueva fn kind_row
- nueva fn kind_row_with_stats
- nueva fn lens_label
- nueva fn lens_labels_cover_all_variants
- nueva fn main
- nueva fn menubar_spec
- nueva fn monad_card
- nueva fn muted_line
- nueva fn on_key
- nueva fn resolve_socket
- nueva fn resolve_socket_fails_with_message_when_nothing_responds
- nueva mod tests
- nueva fn text_line
- nueva fn tick
- nueva fn title
- nueva fn update
- nueva fn view
- nueva fn view_overlay
- nueva fn viewport_of
02_ruway/chasqui/chasqui-nous-mock/src/main.rs
- nueva def MODEL_ID
- nueva fn build_card
- nueva fn handle_conn
- nueva fn handle_embed_file
- nueva fn handle_embed_text
- nueva fn handle_ping
- nueva fn init_tracing
- nueva fn main
- nueva fn now_ms
- nueva fn write_error
02_ruway/chasqui/chasqui-nous-real/src/cache.rs
- nueva struct EmbedCache pub
- nueva fn build_key
- nueva fn corrupted_value_returns_none
- nueva fn decode_embedding
- nueva fn default_path
- nueva fn different_content_different_keys
- nueva fn different_models_do_not_collide
- nueva fn encode_embedding
- nueva fn get pub
- nueva fn len pub
- nueva fn miss_returns_none
- nueva fn open pub
- nueva fn open_at pub
- nueva fn put pub
- nueva fn roundtrip_returns_same_vector
- nueva fn sha
- nueva mod tests
02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs
- nueva struct Backend pub
- nueva def MAX_FILE_BYTES
- nueva fn embed_one
- nueva fn handle_conn pub
- nueva fn handle_file
- nueva fn handle_ping
- nueva fn handle_text
- nueva fn init pub
- nueva fn write_error
02_ruway/chasqui/chasqui-nous-real/src/main.rs
- nueva def EMBED_DIM
- nueva def EMBED_DIM
- nueva def MODEL_ID
- nueva def MODEL_ID
- nueva fn build_card
- nueva mod cache
- nueva def embed_dim
- nueva mod embeddings
- nueva fn init_tracing
- nueva fn main
- nueva def model_id
- nueva mod stub
02_ruway/chasqui/chasqui-nous-real/src/stub.rs
- nueva fn handle_conn pub
02_ruway/chasqui/chasqui-nous/src/lib.rs
- nueva struct EmbedFilePayload pub
- nueva struct EmbedRequest pub
- nueva struct EmbedResponse pub
- nueva struct EmbedTextPayload pub
- nueva struct ErrorResponse pub
- nueva def FLOW_EMBED_REQUEST pub
- nueva def FLOW_EMBED_RESULT pub
- nueva def FLOW_TYPE_NAME pub
- nueva struct PingResponse pub
- nueva enum RequestKind pub
- nueva def SOCKET_ENV pub
- nueva def SOCKET_NAME pub
- nueva fn default_socket_path pub
- nueva fn provider_socket_path pub
- nueva fn request_roundtrip_json
- nueva fn response_roundtrip
- nueva fn runtime_base
- nueva mod tests
- nueva mod transport pub
80 archivos · +22318 −0
- .gitignore +3 −0
- 02_ruway/chasqui/ARQUITECTURA.md +202 −0
- 02_ruway/chasqui/LEEME.md +41 −0
- 02_ruway/chasqui/README.md +38 −0
- 02_ruway/chasqui/README.qu.md +40 −0
- 02_ruway/chasqui/card-admin/Cargo.toml +27 −0
- 02_ruway/chasqui/card-admin/examples/brahman-status.rs +98 −0
- 02_ruway/chasqui/card-admin/src/client.rs +48 −0
- 02_ruway/chasqui/card-admin/src/lib.rs +23 −0
- 02_ruway/chasqui/card-admin/src/server.rs +110 −0
- 02_ruway/chasqui/card-admin/src/snapshot.rs +24 −0
- 02_ruway/chasqui/card-admin/src/transport.rs +20 −0
- 02_ruway/chasqui/card-handshake/Cargo.toml +37 −0
- 02_ruway/chasqui/card-handshake/examples/probe.rs +51 −0
- 02_ruway/chasqui/card-handshake/examples/subscriber.rs +83 −0
- 02_ruway/chasqui/card-handshake/src/client.rs +312 −0
- 02_ruway/chasqui/card-handshake/src/codec.rs +72 −0
- 02_ruway/chasqui/card-handshake/src/identity.rs +358 −0
- 02_ruway/chasqui/card-handshake/src/lib.rs +33 −0
- 02_ruway/chasqui/card-handshake/src/messages.rs +234 −0
- 02_ruway/chasqui/card-handshake/src/network.rs +289 −0
- 02_ruway/chasqui/card-handshake/src/peer_policy.rs +582 −0
- 02_ruway/chasqui/card-handshake/src/server.rs +855 −0
- 02_ruway/chasqui/card-handshake/src/signature.rs +155 −0
- 02_ruway/chasqui/card-handshake/src/transport.rs +52 −0
- 02_ruway/chasqui/card-handshake/tests/handshake.rs +480 −0
- 02_ruway/chasqui/card-handshake/tests/network_discovery.rs +340 −0
- 02_ruway/chasqui/card-handshake/tests/network_libp2p.rs +525 −0
- 02_ruway/chasqui/card-sidecar/Cargo.toml +33 −0
- 02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs +99 −0
- 02_ruway/chasqui/card-sidecar/examples/presence.rs +70 −0
- 02_ruway/chasqui/card-sidecar/src/discovery.rs +453 −0
- 02_ruway/chasqui/card-sidecar/src/lib.rs +255 −0
- 02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs +256 −0
- 02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml +28 −0
- 02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md +16 −0
- 02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md +16 −0
- 02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs +868 −0
- 02_ruway/chasqui/chasqui-broker/Cargo.toml +15 −0
- 02_ruway/chasqui/chasqui-broker/LEEME.md +16 −0
- 02_ruway/chasqui/chasqui-broker/README.md +16 −0
- 02_ruway/chasqui/chasqui-broker/src/lib.rs +995 −0
- 02_ruway/chasqui/chasqui-card/Cargo.toml +16 −0
- 02_ruway/chasqui/chasqui-card/LEEME.md +10 −0
- 02_ruway/chasqui/chasqui-card/README.md +10 −0
- 02_ruway/chasqui/chasqui-card/src/lib.rs +431 −0
- 02_ruway/chasqui/chasqui-card/src/query.rs +385 −0
- 02_ruway/chasqui/chasqui-core/Cargo.toml +33 −0
- 02_ruway/chasqui/chasqui-core/LEEME.md +9 −0
- 02_ruway/chasqui/chasqui-core/README.md +9 −0
- 02_ruway/chasqui/chasqui-core/src/bin/nouser.rs +795 −0
- 02_ruway/chasqui/chasqui-core/src/cluster.rs +355 −0
- 02_ruway/chasqui/chasqui-core/src/db.rs +313 −0
- 02_ruway/chasqui/chasqui-core/src/embed.rs +298 −0
- 02_ruway/chasqui/chasqui-core/src/engine_socket.rs +318 −0
- 02_ruway/chasqui/chasqui-core/src/lib.rs +34 −0
- 02_ruway/chasqui/chasqui-core/src/scanner.rs +178 −0
- 02_ruway/chasqui/chasqui-explorer-llimphi/Cargo.toml +28 −0
- 02_ruway/chasqui/chasqui-explorer-llimphi/LEEME.md +16 −0
- 02_ruway/chasqui/chasqui-explorer-llimphi/README.md +16 −0
- 02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs +800 −0
- 02_ruway/chasqui/chasqui-nous-mock/Cargo.toml +25 −0
- 02_ruway/chasqui/chasqui-nous-mock/LEEME.md +10 −0
- 02_ruway/chasqui/chasqui-nous-mock/README.md +10 −0
- 02_ruway/chasqui/chasqui-nous-mock/src/main.rs +247 −0
- 02_ruway/chasqui/chasqui-nous-real/Cargo.toml +40 −0
- 02_ruway/chasqui/chasqui-nous-real/LEEME.md +10 −0
- 02_ruway/chasqui/chasqui-nous-real/README.md +10 −0
- 02_ruway/chasqui/chasqui-nous-real/src/cache.rs +199 −0
- 02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs +205 −0
- 02_ruway/chasqui/chasqui-nous-real/src/main.rs +202 −0
- 02_ruway/chasqui/chasqui-nous-real/src/stub.rs +36 −0
- 02_ruway/chasqui/chasqui-nous/Cargo.toml +14 −0
- 02_ruway/chasqui/chasqui-nous/LEEME.md +10 −0
- 02_ruway/chasqui/chasqui-nous/README.md +10 −0
- 02_ruway/chasqui/chasqui-nous/src/lib.rs +196 −0
- Cargo.lock +8292 −0
- Cargo.toml +448 −0
- LICENSE +21 −0
- README.md +11 −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.