Skip to main content

clawft_kernel/
cluster.rs

1//! Cluster membership and node fabric.
2//!
3//! Defines types for multi-node WeftOS clusters where agents
4//! can migrate between nodes. Each node is a WeftOS kernel
5//! instance -- native binary on cloud/edge, or WASM in a browser.
6//!
7//! # Feature Gate
8//!
9//! All types compile unconditionally. Actual peer discovery,
10//! health monitoring, and cross-node communication require the
11//! `cluster` feature flag and a distributed networking layer.
12
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15#[cfg(any(feature = "exochain", all(feature = "mesh", feature = "native")))]
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use chrono::{DateTime, Utc};
20use dashmap::DashMap;
21use serde::{Deserialize, Serialize};
22use tracing::{debug, info, warn};
23
24/// Unique node identifier (UUID or DID string).
25pub type NodeId = String;
26
27/// Node platform type.
28#[non_exhaustive]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub enum NodePlatform {
31    /// Native binary on a cloud VM or bare metal server.
32    CloudNative,
33    /// Native binary on an edge device.
34    Edge,
35    /// WASM module running in a browser tab.
36    Browser,
37    /// WASI module in a container.
38    Wasi,
39    /// Custom platform label.
40    Custom(String),
41}
42
43impl std::fmt::Display for NodePlatform {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            NodePlatform::CloudNative => write!(f, "cloud-native"),
47            NodePlatform::Edge => write!(f, "edge"),
48            NodePlatform::Browser => write!(f, "browser"),
49            NodePlatform::Wasi => write!(f, "wasi"),
50            NodePlatform::Custom(name) => write!(f, "custom({name})"),
51        }
52    }
53}
54
55/// How long an `Unverified` mesh peer may go unheard before membership drops
56/// it (see [`ClusterMembership::expire_unverified`]).
57pub const UNVERIFIED_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
58/// How often the mesh listeners look for expired `Unverified` peers.
59pub const UNVERIFIED_REAP_EVERY: std::time::Duration = std::time::Duration::from_secs(60);
60
61/// Node health state.
62#[non_exhaustive]
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum NodeState {
65    /// Node is joining the cluster.
66    Joining,
67    /// Node is healthy and active.
68    Active,
69    /// Node is suspected unreachable (missed heartbeats).
70    Suspect,
71    /// Node has been confirmed unreachable.
72    Unreachable,
73    /// Node is gracefully leaving the cluster.
74    Leaving,
75    /// Node has left the cluster.
76    Left,
77    /// A connected mesh peer whose node id admission did not verify (every
78    /// peer under `off` / `observe`, and dialled seeds): its routes work, but
79    /// the id is only a claim, so it is not a member. Only a verified join
80    /// makes it `Active`; heartbeats and health events never do.
81    Unverified,
82}
83
84impl std::fmt::Display for NodeState {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            NodeState::Joining => write!(f, "joining"),
88            NodeState::Active => write!(f, "active"),
89            NodeState::Suspect => write!(f, "suspect"),
90            NodeState::Unreachable => write!(f, "unreachable"),
91            NodeState::Leaving => write!(f, "leaving"),
92            NodeState::Left => write!(f, "left"),
93            NodeState::Unverified => write!(f, "unverified"),
94        }
95    }
96}
97
98/// Information about a peer node in the cluster.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PeerNode {
101    /// Unique node identifier.
102    pub id: NodeId,
103
104    /// Human-readable node name.
105    pub name: String,
106
107    /// Node platform.
108    pub platform: NodePlatform,
109
110    /// Current state in the cluster.
111    pub state: NodeState,
112
113    /// Network address for direct communication.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub address: Option<String>,
116
117    /// When this node was first seen.
118    pub first_seen: DateTime<Utc>,
119
120    /// When the last heartbeat was received.
121    pub last_heartbeat: DateTime<Utc>,
122
123    /// Capabilities this node advertises.
124    #[serde(default)]
125    pub capabilities: Vec<String>,
126
127    /// Labels for scheduling and filtering.
128    #[serde(default)]
129    pub labels: HashMap<String, String>,
130}
131
132/// Cluster configuration.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ClusterConfig {
135    /// This node's identifier.
136    pub node_id: NodeId,
137
138    /// This node's display name.
139    #[serde(default = "default_node_name")]
140    pub node_name: String,
141
142    /// This node's platform type.
143    #[serde(default = "default_platform")]
144    pub platform: NodePlatform,
145
146    /// Heartbeat interval in seconds.
147    #[serde(default = "default_heartbeat_interval")]
148    pub heartbeat_interval_secs: u64,
149
150    /// How many missed heartbeats before marking a node suspect.
151    #[serde(default = "default_suspect_threshold")]
152    pub suspect_threshold: u32,
153
154    /// How many missed heartbeats before marking a node unreachable.
155    #[serde(default = "default_unreachable_threshold")]
156    pub unreachable_threshold: u32,
157
158    /// Maximum cluster size (0 = unlimited).
159    #[serde(default)]
160    pub max_nodes: u32,
161
162    /// Address to bind the mesh listener (e.g., "0.0.0.0:9489").
163    #[serde(default)]
164    pub bind_address: Option<String>,
165
166    /// Seed peers for bootstrap discovery.
167    #[serde(default)]
168    pub seed_peers: Vec<String>,
169
170    /// Path to Ed25519 identity key file.
171    #[serde(default)]
172    pub identity_key_path: Option<std::path::PathBuf>,
173
174    /// Max `add_peer` calls allowed per source within
175    /// [`Self::peer_add_window_ms`] (WEFT-148).
176    ///
177    /// `0` disables peer-add rate limiting. Default: 1.
178    #[serde(default = "default_peer_add_max_per_source")]
179    pub peer_add_max_per_source: u32,
180
181    /// Sliding window for peer-add rate limiting, in milliseconds.
182    /// Default: 100.
183    #[serde(default = "default_peer_add_window_ms")]
184    pub peer_add_window_ms: u64,
185}
186
187fn default_node_name() -> String {
188    "local".into()
189}
190
191fn default_platform() -> NodePlatform {
192    NodePlatform::CloudNative
193}
194
195fn default_heartbeat_interval() -> u64 {
196    5
197}
198
199fn default_suspect_threshold() -> u32 {
200    3
201}
202
203fn default_unreachable_threshold() -> u32 {
204    10
205}
206
207fn default_peer_add_max_per_source() -> u32 {
208    crate::rate_limit::DEFAULT_PEER_ADD_RATE.max_per_window
209}
210
211fn default_peer_add_window_ms() -> u64 {
212    crate::rate_limit::DEFAULT_PEER_ADD_RATE.window.as_millis() as u64
213}
214
215impl Default for ClusterConfig {
216    fn default() -> Self {
217        Self {
218            node_id: uuid::Uuid::new_v4().to_string(),
219            node_name: default_node_name(),
220            platform: default_platform(),
221            heartbeat_interval_secs: default_heartbeat_interval(),
222            suspect_threshold: default_suspect_threshold(),
223            unreachable_threshold: default_unreachable_threshold(),
224            max_nodes: 0,
225            bind_address: None,
226            seed_peers: Vec::new(),
227            identity_key_path: None,
228            peer_add_max_per_source: default_peer_add_max_per_source(),
229            peer_add_window_ms: default_peer_add_window_ms(),
230        }
231    }
232}
233
234impl ClusterConfig {
235    /// Build a [`crate::rate_limit::RateLimitConfig`] from the peer-add
236    /// rate-limit fields (WEFT-148).
237    pub fn peer_add_rate_config(&self) -> crate::rate_limit::RateLimitConfig {
238        crate::rate_limit::RateLimitConfig {
239            max_per_window: self.peer_add_max_per_source,
240            window: Duration::from_millis(self.peer_add_window_ms),
241        }
242    }
243
244    /// Recommend the heartbeat / gossip interval (seconds), consulting
245    /// an optional learned
246    /// [`GossipTimingModel`](crate::eml_kernel::GossipTimingModel).
247    ///
248    /// When `model` is `None` or untrained, returns
249    /// `self.heartbeat_interval_secs` unchanged (current behaviour).
250    /// When trained, the model's per-context recommendation is used,
251    /// rounded to whole seconds and clamped to the model's [1, 60]
252    /// range.
253    ///
254    /// `peer_count` and `network_latency_ms` and `update_frequency_hz`
255    /// feed the model when trained; pass `(0, 0.0, 0.0)` if not
256    /// available.
257    ///
258    /// Without the `ecc` feature this always returns
259    /// `self.heartbeat_interval_secs` (WEFT-114).
260    ///
261    /// NOTE(eml-swap): wired — Finding #5 (GossipTimingModel).
262    #[cfg(feature = "ecc")]
263    pub fn recommended_heartbeat_secs(
264        &self,
265        model: Option<&crate::eml_kernel::GossipTimingModel>,
266        peer_count: usize,
267        network_latency_ms: f64,
268        update_frequency_hz: f64,
269    ) -> u64 {
270        match model {
271            Some(m) if m.is_trained() => {
272                let secs = m.predict(peer_count, network_latency_ms, update_frequency_hz);
273                secs.round().max(1.0) as u64
274            }
275            _ => self.heartbeat_interval_secs,
276        }
277    }
278
279    /// Recommend heartbeat interval without EML (no `ecc` feature).
280    #[cfg(not(feature = "ecc"))]
281    pub fn recommended_heartbeat_secs(&self) -> u64 {
282        self.heartbeat_interval_secs
283    }
284}
285
286// ── ECC capability advertisement (K3c) ────────────────────────────
287
288/// ECC capabilities advertised by a cluster node.
289///
290/// Populated during boot-time calibration and advertised to peers
291/// so they can route ECC-related requests to capable nodes.
292#[cfg(feature = "ecc")]
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct NodeEccCapability {
295    /// Calibrated cognitive tick interval (milliseconds).
296    pub tick_interval_ms: u32,
297    /// 95th percentile compute time per tick (microseconds).
298    pub compute_p95_us: u32,
299    /// Headroom ratio (actual_compute / budget).
300    pub headroom_ratio: f32,
301    /// Number of vectors in the HNSW index.
302    pub hnsw_vector_count: u32,
303    /// Number of edges in the causal graph.
304    pub causal_edge_count: u32,
305    /// Whether this node can perform spectral analysis.
306    pub spectral_capable: bool,
307    /// Unix timestamp when calibration was performed.
308    pub calibrated_at: u64,
309}
310
311// ── Node identity (K6 mesh networking) ─────────────────────────────
312
313/// Node identity derived from Ed25519 keypair.
314///
315/// The `node_id` is derived as `hex(SHA-256(pubkey)[0..16])` (ADR-103 D11),
316/// providing a stable, compact identifier tied to the cryptographic key.
317#[cfg(any(feature = "mesh", feature = "exochain"))]
318pub struct NodeIdentity {
319    /// Ed25519 signing key (private).
320    keypair: ed25519_dalek::SigningKey,
321    /// Derived node identifier.
322    node_id: String,
323}
324
325#[cfg(any(feature = "mesh", feature = "exochain"))]
326impl NodeIdentity {
327    /// Generate a new random identity.
328    pub fn generate() -> Self {
329        let mut csprng = rand::thread_rng();
330        Self::from_signing_key(ed25519_dalek::SigningKey::generate(&mut csprng))
331    }
332
333    /// Build an identity from an existing Ed25519 signing key (for
334    /// example the persisted `<runtime>/node.key`). The node id comes
335    /// from [`crate::node_id::node_id_from_pubkey`], the one derivation.
336    pub fn from_signing_key(keypair: ed25519_dalek::SigningKey) -> Self {
337        let node_id = crate::node_id::node_id_from_pubkey(&keypair.verifying_key().to_bytes());
338        Self { keypair, node_id }
339    }
340
341    /// Get this node's identifier.
342    pub fn node_id(&self) -> &str {
343        &self.node_id
344    }
345
346    /// Get the public verification key.
347    pub fn public_key(&self) -> ed25519_dalek::VerifyingKey {
348        self.keypair.verifying_key()
349    }
350
351    /// Sign arbitrary data with this node's private key.
352    pub fn sign(&self, data: &[u8]) -> ed25519_dalek::Signature {
353        use ed25519_dalek::Signer;
354        self.keypair.sign(data)
355    }
356}
357
358/// Cluster membership errors.
359#[non_exhaustive]
360#[derive(Debug, thiserror::Error)]
361pub enum ClusterError {
362    /// Node already in the cluster.
363    #[error("node already exists: '{node_id}'")]
364    NodeAlreadyExists {
365        /// Node ID.
366        node_id: NodeId,
367    },
368
369    /// Node not found.
370    #[error("node not found: '{node_id}'")]
371    NodeNotFound {
372        /// Node ID.
373        node_id: NodeId,
374    },
375
376    /// Cluster is at maximum capacity.
377    #[error("cluster full: max {max} nodes")]
378    ClusterFull {
379        /// Maximum node count.
380        max: u32,
381    },
382
383    /// Mesh networking error.
384    #[error("mesh error: {0}")]
385    Mesh(String),
386
387    /// Authentication failed during cluster join.
388    #[error("authentication failed: {0}")]
389    AuthFailed(String),
390
391    /// Invalid state transition.
392    #[error("invalid node state transition: {from} -> {to}")]
393    InvalidTransition {
394        /// Current state.
395        from: String,
396        /// Requested state.
397        to: String,
398    },
399
400    /// Peer additions are too frequent (rate limited).
401    #[error("rate limited: peer additions too frequent")]
402    RateLimited,
403}
404
405/// Cluster membership tracker.
406///
407/// Tracks which nodes are part of the cluster, their health state,
408/// and capabilities. Actual peer discovery and heartbeat monitoring
409/// require a networking layer not included here.
410/// Known valid capabilities for cluster nodes.
411const KNOWN_CAPABILITIES: &[&str] = &[
412    "ipc",
413    "chain",
414    "tree",
415    "governance",
416    "ecc",
417    "wasm",
418    "containers",
419    "apps",
420    "mesh",
421    "discovery",
422    "heartbeat",
423    "compute",
424];
425
426/// Validate peer capabilities against the known set.
427/// Returns unknown capabilities (if any).
428fn validate_capabilities(capabilities: &[String]) -> Vec<String> {
429    capabilities
430        .iter()
431        .filter(|c| !KNOWN_CAPABILITIES.contains(&c.as_str()))
432        .cloned()
433        .collect()
434}
435
436/// On-disk format for persisted cluster peers.
437#[derive(Debug, Clone, Serialize, Deserialize, Default)]
438pub struct ClusterPeersFile {
439    /// Schema version for forward compatibility.
440    #[serde(default = "default_peers_file_version")]
441    pub version: u32,
442    /// Persisted peer records.
443    #[serde(default)]
444    pub peers: Vec<PeerNode>,
445}
446
447fn default_peers_file_version() -> u32 {
448    1
449}
450
451pub struct ClusterMembership {
452    config: ClusterConfig,
453    peers: DashMap<NodeId, PeerNode>,
454    /// Per-source rate limiter for `add_peer` (WEFT-148).
455    peer_add_limiter: crate::rate_limit::KeyedRateLimiter,
456    /// Optional path for peer membership persistence.
457    ///
458    /// When set, mutations (add/remove/state-change) are reflected to disk
459    /// so the cluster survives kernel restarts. A `Mutex` serialises writers;
460    /// the lock is held only for the JSON encode + atomic rename.
461    persist_path: std::sync::Mutex<Option<PathBuf>>,
462    /// Optional chain manager for exochain audit logging.
463    #[cfg(feature = "exochain")]
464    chain: Option<Arc<crate::chain::ChainManager>>,
465    /// Optional governance gate for policy enforcement.
466    #[cfg(feature = "exochain")]
467    gate: Option<Arc<crate::gate::GovernanceGate>>,
468    /// Verified, signed node facts with TTL (ADR-099 section 2).
469    #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
470    facts: crate::node_facts::NodeFactsCache,
471}
472
473impl ClusterMembership {
474    /// Create a new cluster membership tracker.
475    ///
476    /// Peer-add rate limiting is initialised from
477    /// [`ClusterConfig::peer_add_rate_config`] (defaults: 1 per source
478    /// per 100 ms).
479    pub fn new(config: ClusterConfig) -> Self {
480        let peer_add_limiter =
481            crate::rate_limit::KeyedRateLimiter::new(config.peer_add_rate_config());
482        Self {
483            config,
484            peers: DashMap::new(),
485            peer_add_limiter,
486            persist_path: std::sync::Mutex::new(None),
487            #[cfg(feature = "exochain")]
488            chain: None,
489            #[cfg(feature = "exochain")]
490            gate: None,
491            #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
492            facts: crate::node_facts::NodeFactsCache::new(),
493        }
494    }
495
496    /// Verified node facts (local and peers) with TTL (card mesh-placement-03).
497    #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
498    pub fn facts(&self) -> &crate::node_facts::NodeFactsCache {
499        &self.facts
500    }
501
502    /// Attach a chain manager for audit logging (builder style).
503    #[cfg(feature = "exochain")]
504    pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
505        self.chain = Some(cm);
506        self
507    }
508
509    /// Attach a governance gate for policy enforcement (builder style).
510    #[cfg(feature = "exochain")]
511    pub fn with_gate(mut self, gate: Arc<crate::gate::GovernanceGate>) -> Self {
512        self.gate = Some(gate);
513        self
514    }
515
516    /// Set the minimum interval between peer additions from the *same*
517    /// source (builder style).
518    ///
519    /// `Duration::ZERO` disables rate limiting (used by unit tests).
520    /// Non-zero intervals configure a per-source limit of 1 addition
521    /// per `interval`. Prefer [`Self::with_peer_add_rate_limit`] for
522    /// multi-count windows.
523    pub fn with_min_peer_interval(self, interval: std::time::Duration) -> Self {
524        if interval.is_zero() {
525            self.with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig::unlimited())
526        } else {
527            self.with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig {
528                max_per_window: 1,
529                window: interval,
530            })
531        }
532    }
533
534    /// Configure the per-source `add_peer` rate limit (WEFT-148).
535    pub fn with_peer_add_rate_limit(
536        self,
537        config: crate::rate_limit::RateLimitConfig,
538    ) -> Self {
539        self.peer_add_limiter.set_config(config);
540        self
541    }
542
543    /// Current peer-add rate-limit configuration.
544    pub fn peer_add_rate_limit(&self) -> crate::rate_limit::RateLimitConfig {
545        self.peer_add_limiter.config()
546    }
547
548    /// Resolve the rate-limit source key for a peer.
549    ///
550    /// Prefers network `address` when present (so one host flooding many
551    /// node IDs still hits the same bucket); falls back to peer `id`.
552    pub fn peer_add_source_key(peer: &PeerNode) -> &str {
553        match &peer.address {
554            Some(addr) if !addr.is_empty() => addr.as_str(),
555            _ => peer.id.as_str(),
556        }
557    }
558
559    /// Enable peer persistence at the given path (builder style).
560    ///
561    /// If the file exists and is readable, peers are rehydrated into the
562    /// in-memory map so joins survive kernel restarts. Parse errors log a
563    /// warning and leave the map empty. Subsequent mutations write back to
564    /// the same path via [`Self::persist`].
565    pub fn with_persist_path(self, path: impl AsRef<Path>) -> Self {
566        let path = path.as_ref().to_path_buf();
567        let mut prune_file = false;
568        if path.exists() {
569            match std::fs::read_to_string(&path) {
570                Ok(data) => match serde_json::from_str::<ClusterPeersFile>(&data) {
571                    Ok(file) => {
572                        let mut legacy = Vec::new();
573                        for peer in file.peers {
574                            // Pre-0.8 daemons keyed peers by UUID; node ids are now
575                            // 32-hex from the node key, so a UUID entry can never match a
576                            // live peer again (phase-0 review R8).
577                            if is_legacy_uuid_id(&peer.id) {
578                                legacy.push(peer.id);
579                                continue;
580                            }
581                            // Bypass rate-limiting and duplicate checks during rehydration.
582                            self.peers.insert(peer.id.clone(), peer);
583                        }
584                        info!(
585                            count = self.peers.len(),
586                            path = %path.display(),
587                            "rehydrated cluster peers from disk"
588                        );
589                        if !legacy.is_empty() {
590                            warn!(
591                                dropped = legacy.len(),
592                                ids = ?legacy,
593                                path = %path.display(),
594                                "dropped legacy UUID peer ids from the cluster peers file"
595                            );
596                            prune_file = true;
597                        }
598                    }
599                    Err(e) => {
600                        warn!(
601                            error = %e,
602                            path = %path.display(),
603                            "failed to parse cluster peers file; starting empty"
604                        );
605                    }
606                },
607                Err(e) => {
608                    warn!(
609                        error = %e,
610                        path = %path.display(),
611                        "failed to read cluster peers file; starting empty"
612                    );
613                }
614            }
615        }
616        *self
617            .persist_path
618            .lock()
619            .expect("persist_path lock poisoned") = Some(path);
620        if prune_file {
621            // Write the cleaned list back so the legacy ids are gone from disk too.
622            self.persist();
623        }
624        self
625    }
626
627    /// Snapshot current peers to disk (no-op when persistence is disabled).
628    ///
629    /// Writes to a sibling `.tmp` file then renames for atomic replacement.
630    /// Errors are logged but not propagated — membership stays correct
631    /// in memory, and the next successful mutation will re-sync the file.
632    fn persist(&self) {
633        let path_opt = self
634            .persist_path
635            .lock()
636            .expect("persist_path lock poisoned")
637            .clone();
638        let Some(path) = path_opt else {
639            return;
640        };
641
642        let peers: Vec<PeerNode> = self.peers.iter().map(|e| e.value().clone()).collect();
643        let file = ClusterPeersFile {
644            version: default_peers_file_version(),
645            peers,
646        };
647        let json = match serde_json::to_string_pretty(&file) {
648            Ok(j) => j,
649            Err(e) => {
650                warn!(error = %e, "failed to serialise cluster peers");
651                return;
652            }
653        };
654
655        if let Some(parent) = path.parent()
656            && let Err(e) = std::fs::create_dir_all(parent)
657        {
658            warn!(error = %e, path = %parent.display(), "failed to create persist parent");
659            return;
660        }
661
662        let tmp = path.with_extension("json.tmp");
663        if let Err(e) = std::fs::write(&tmp, &json) {
664            warn!(error = %e, path = %tmp.display(), "failed to write cluster peers tmp");
665            return;
666        }
667        if let Err(e) = std::fs::rename(&tmp, &path) {
668            warn!(error = %e, path = %path.display(), "failed to rename cluster peers file");
669            let _ = std::fs::remove_file(&tmp);
670        }
671    }
672
673    /// Get the cluster configuration.
674    pub fn config(&self) -> &ClusterConfig {
675        &self.config
676    }
677
678    /// Get this node's ID.
679    pub fn local_node_id(&self) -> &str {
680        &self.config.node_id
681    }
682
683    /// Register a peer node.
684    ///
685    /// Rate-limited **per source** (WEFT-148): by default at most one
686    /// addition per source per 100 ms. Source key is the peer's network
687    /// address when set, otherwise its node id. Thresholds are
688    /// configurable via [`ClusterConfig`] /
689    /// [`Self::with_peer_add_rate_limit`].
690    pub fn add_peer(&self, peer: PeerNode) -> Result<(), ClusterError> {
691        // Governance gate: check policy before allowing peer addition.
692        #[cfg(feature = "exochain")]
693        if let Some(ref gate) = self.gate {
694            use crate::gate::GateBackend;
695            let decision = gate.check(
696                &self.config.node_id,
697                "cluster.peer.add",
698                &serde_json::json!({
699                    "peer_id": peer.id,
700                    "peer_name": peer.name,
701                    "platform": peer.platform.to_string(),
702                    "effect": { "risk": 0.4, "security": 0.3 },
703                }),
704            );
705            if decision.is_deny() {
706                return Err(ClusterError::AuthFailed(
707                    "governance denied peer addition".into(),
708                ));
709            }
710        }
711
712        // Rate-limit peer additions per source (WEFT-148).
713        let source = Self::peer_add_source_key(&peer);
714        if !self.peer_add_limiter.check(source) {
715            return Err(ClusterError::RateLimited);
716        }
717
718        if self.peers.contains_key(&peer.id) {
719            return Err(ClusterError::NodeAlreadyExists { node_id: peer.id });
720        }
721
722        if self.config.max_nodes > 0 && self.peers.len() as u32 >= self.config.max_nodes {
723            return Err(ClusterError::ClusterFull {
724                max: self.config.max_nodes,
725            });
726        }
727
728        // Validate capabilities -- warn on unknown but still allow (forward compat).
729        let unknown = validate_capabilities(&peer.capabilities);
730        if !unknown.is_empty() {
731            warn!(
732                node_id = %peer.id,
733                unknown_capabilities = ?unknown,
734                "peer advertises unknown capabilities"
735            );
736        }
737
738        // Chain logging: record peer addition.
739        #[cfg(feature = "exochain")]
740        if let Some(ref cm) = self.chain {
741            cm.append(
742                "cluster",
743                crate::chain::EVENT_KIND_CLUSTER_PEER_ADD,
744                Some(serde_json::json!({
745                    "node_id": peer.id,
746                    "name": peer.name,
747                    "platform": peer.platform.to_string(),
748                })),
749            );
750        }
751
752        debug!(node_id = %peer.id, name = %peer.name, "adding peer to cluster");
753        self.peers.insert(peer.id.clone(), peer);
754        self.persist();
755        Ok(())
756    }
757
758    /// Remove a peer node.
759    pub fn remove_peer(&self, node_id: &str) -> Result<PeerNode, ClusterError> {
760        // Governance gate: check policy before allowing peer removal.
761        #[cfg(feature = "exochain")]
762        if let Some(ref gate) = self.gate {
763            use crate::gate::GateBackend;
764            let decision = gate.check(
765                &self.config.node_id,
766                "cluster.peer.remove",
767                &serde_json::json!({
768                    "peer_id": node_id,
769                    "effect": { "risk": 0.3, "security": 0.2 },
770                }),
771            );
772            if decision.is_deny() {
773                return Err(ClusterError::AuthFailed(
774                    "governance denied peer removal".into(),
775                ));
776            }
777        }
778
779        let result = self
780            .peers
781            .remove(node_id)
782            .map(|(_, peer)| peer)
783            .ok_or_else(|| ClusterError::NodeNotFound {
784                node_id: node_id.to_owned(),
785            });
786
787        if result.is_ok() {
788            self.persist();
789        }
790
791        // Chain logging: record peer removal on success.
792        #[cfg(feature = "exochain")]
793        if let (Ok(peer), Some(cm)) = (&result, &self.chain) {
794            cm.append(
795                "cluster",
796                crate::chain::EVENT_KIND_CLUSTER_PEER_REMOVE,
797                Some(serde_json::json!({
798                    "node_id": peer.id,
799                    "name": peer.name,
800                })),
801            );
802        }
803
804        result
805    }
806
807    /// Update a peer's state.
808    pub fn update_state(&self, node_id: &str, new_state: NodeState) -> Result<(), ClusterError> {
809        let mut entry = self
810            .peers
811            .get_mut(node_id)
812            .ok_or_else(|| ClusterError::NodeNotFound {
813                node_id: node_id.to_owned(),
814            })?;
815
816        // Chain logging: record state transition.
817        #[cfg(feature = "exochain")]
818        if let Some(ref cm) = self.chain {
819            cm.append(
820                "cluster",
821                crate::chain::EVENT_KIND_CLUSTER_PEER_STATE,
822                Some(serde_json::json!({
823                    "node_id": node_id,
824                    "from": entry.state.to_string(),
825                    "to": new_state.to_string(),
826                })),
827            );
828        }
829
830        entry.state = new_state;
831        drop(entry);
832        self.persist();
833        Ok(())
834    }
835
836    /// Record a heartbeat from a peer.
837    pub fn heartbeat(&self, node_id: &str) -> Result<(), ClusterError> {
838        let mut entry = self
839            .peers
840            .get_mut(node_id)
841            .ok_or_else(|| ClusterError::NodeNotFound {
842                node_id: node_id.to_owned(),
843            })?;
844        entry.last_heartbeat = Utc::now();
845        if entry.state == NodeState::Suspect {
846            entry.state = NodeState::Active;
847        }
848        Ok(())
849    }
850
851    /// Get a snapshot of a peer's state.
852    pub fn get_peer(&self, node_id: &str) -> Option<PeerNode> {
853        self.peers.get(node_id).map(|e| e.value().clone())
854    }
855
856    /// List all peers with their states.
857    pub fn list_peers(&self) -> Vec<(NodeId, NodeState, NodePlatform)> {
858        self.peers
859            .iter()
860            .map(|e| (e.key().clone(), e.state.clone(), e.platform.clone()))
861            .collect()
862    }
863
864    /// Count peers by state.
865    pub fn count_by_state(&self, state: &NodeState) -> usize {
866        self.peers.iter().filter(|e| &e.state == state).count()
867    }
868
869    /// Count total peers.
870    pub fn len(&self) -> usize {
871        self.peers.len()
872    }
873
874    /// Check if cluster has no peers.
875    pub fn is_empty(&self) -> bool {
876        self.peers.is_empty()
877    }
878
879    /// Add a peer and optionally create a resource tree node.
880    #[cfg(feature = "exochain")]
881    pub fn add_peer_with_tree(
882        &self,
883        peer: PeerNode,
884        tree: &std::sync::Mutex<exo_resource_tree::ResourceTree>,
885    ) -> Result<(), ClusterError> {
886        let peer_name = peer.name.clone();
887        self.add_peer(peer)?;
888
889        // Create tree node for this peer
890        let mut tree = tree.lock().unwrap();
891        let peer_id = exo_resource_tree::ResourceId::new(format!("/network/peers/{peer_name}"));
892        let parent = exo_resource_tree::ResourceId::new("/network/peers");
893        if let Err(e) = tree.insert(peer_id, exo_resource_tree::ResourceKind::Device, parent) {
894            tracing::debug!(peer = %peer_name, error = %e, "failed to create tree node for peer");
895        }
896
897        Ok(())
898    }
899
900    /// Register a peer node, checking the revocation list first.
901    ///
902    /// If the peer's ID appears in the revocation list, the add is
903    /// rejected with [`ClusterError::AuthFailed`].
904    pub fn add_peer_checked(
905        &self,
906        peer: PeerNode,
907        revocation_list: &crate::revocation::RevocationList,
908    ) -> Result<(), ClusterError> {
909        if revocation_list.is_revoked(&peer.id) {
910            return Err(ClusterError::AuthFailed(format!(
911                "host '{}' is revoked",
912                peer.id,
913            )));
914        }
915        self.add_peer(peer)
916    }
917
918    /// Get all active peer node IDs.
919    pub fn active_peers(&self) -> Vec<NodeId> {
920        self.peers
921            .iter()
922            .filter(|e| e.state == NodeState::Active)
923            .map(|e| e.key().clone())
924            .collect()
925    }
926
927    /// Drop `Unverified` peers not heard from for `max_age`. They are not
928    /// members, so nothing else ages them out (a peer that never sends a
929    /// `Left`, or whose route vanished without an event, would linger).
930    /// Returns how many were dropped.
931    pub fn expire_unverified(&self, max_age: std::time::Duration) -> usize {
932        let cutoff = Utc::now() - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::MAX);
933        let stale: Vec<NodeId> = self
934            .peers
935            .iter()
936            .filter(|e| e.state == NodeState::Unverified && e.last_heartbeat < cutoff)
937            .map(|e| e.key().clone())
938            .collect();
939        stale.iter().filter(|id| self.remove_peer(id).is_ok()).count()
940    }
941
942    /// Apply a live mesh peer event to membership **atomically** (WEFT-120).
943    ///
944    /// Mesh is the authority for connect/health; this path upserts peers
945    /// without the operator-facing `add_peer` rate limit (mesh already
946    /// authenticated the session). A single event produces one consistent
947    /// membership mutation + one persist.
948    ///
949    /// Returns `true` when membership changed.
950    #[cfg(feature = "mesh")]
951    pub fn apply_mesh_peer_event(
952        &self,
953        event: &crate::mesh_discovery::MeshPeerEvent,
954    ) -> Result<bool, ClusterError> {
955        use crate::mesh_discovery::MeshPeerEvent;
956        match event {
957            MeshPeerEvent::Joined {
958                node_id,
959                address,
960                platform,
961                verified,
962            } => self.mesh_upsert(node_id, address.as_deref(), platform.as_deref(), *verified),
963            MeshPeerEvent::Recovered {
964                node_id,
965                address,
966                verified,
967            } => self.mesh_upsert(node_id, address.as_deref(), None, *verified),
968            MeshPeerEvent::Alive { node_id } => {
969                // An unverified id never gets liveness credit.
970                if self
971                    .get_peer(node_id)
972                    .is_some_and(|p| p.state == NodeState::Unverified)
973                {
974                    return Ok(false);
975                }
976                if self.peers.contains_key(node_id) {
977                    let _ = self.heartbeat(node_id);
978                    if self
979                        .get_peer(node_id)
980                        .map(|p| !matches!(p.state, NodeState::Active | NodeState::Unverified))
981                        .unwrap_or(false)
982                    {
983                        let _ = self.update_state(node_id, NodeState::Active);
984                        return Ok(true);
985                    }
986                    // heartbeat already updated last_heartbeat in-place
987                    Ok(true)
988                } else {
989                    // Alive without prior join — treat as soft join. Nothing
990                    // here says the id was verified.
991                    self.mesh_upsert(node_id, None, None, false)
992                }
993            }
994            // An unverified peer's health is not tracked here: a Suspect state
995            // would be turned into Active by the next heartbeat.
996            MeshPeerEvent::Suspect { node_id } => {
997                if self.peers.get(node_id).is_some_and(|p| p.state != NodeState::Unverified) {
998                    self.update_state(node_id, NodeState::Suspect)?;
999                    Ok(true)
1000                } else {
1001                    Ok(false)
1002                }
1003            }
1004            MeshPeerEvent::Unreachable { node_id } => {
1005                match self.peers.get(node_id).map(|p| p.state.clone()) {
1006                    // Its route is gone and it was never a member: drop it,
1007                    // or it would sit in the list until a TTL.
1008                    Some(NodeState::Unverified) => {
1009                        let _ = self.remove_peer(node_id);
1010                        Ok(true)
1011                    }
1012                    Some(_) => {
1013                        self.update_state(node_id, NodeState::Unreachable)?;
1014                        Ok(true)
1015                    }
1016                    None => Ok(false),
1017                }
1018            }
1019            MeshPeerEvent::Left { node_id } => {
1020                if self.peers.contains_key(node_id) {
1021                    // Mark Left then drop so membership no longer lists the peer.
1022                    let _ = self.update_state(node_id, NodeState::Left);
1023                    let _ = self.remove_peer(node_id);
1024                    Ok(true)
1025                } else {
1026                    Ok(false)
1027                }
1028            }
1029        }
1030    }
1031
1032    /// Insert-or-activate a peer from mesh (bypasses rate limit).
1033    ///
1034    /// `verified`: admission verified the node id. A verified join makes the
1035    /// peer `Active`. An unverified one adds a new peer as `Unverified`, and
1036    /// on a peer already held only revives one that was down; it never
1037    /// demotes an `Active` peer (an operator-added or earlier verified one).
1038    #[cfg(feature = "mesh")]
1039    fn mesh_upsert(
1040        &self,
1041        node_id: &str,
1042        address: Option<&str>,
1043        platform: Option<&str>,
1044        verified: bool,
1045    ) -> Result<bool, ClusterError> {
1046        if let Some(mut entry) = self.peers.get_mut(node_id) {
1047            let mut changed = false;
1048            let to = match (verified, &entry.state) {
1049                (true, s) if *s != NodeState::Active => Some(NodeState::Active),
1050                (false, NodeState::Active | NodeState::Unverified) => None,
1051                (false, _) => Some(NodeState::Unverified),
1052                _ => None,
1053            };
1054            if let Some(to) = to {
1055                entry.state = to;
1056                changed = true;
1057            }
1058            // An unverified announce must not refresh a verified peer's
1059            // last-seen time or move its address (it could be spoofed); it
1060            // may only touch an entry that is itself unverified.
1061            let may_refresh = verified || entry.state == NodeState::Unverified;
1062            if may_refresh {
1063                entry.last_heartbeat = Utc::now();
1064            }
1065            if may_refresh
1066                && let Some(addr) = address
1067                && entry.address.as_deref() != Some(addr)
1068            {
1069                entry.address = Some(addr.to_owned());
1070                changed = true;
1071            }
1072            drop(entry);
1073            if changed {
1074                self.persist();
1075            }
1076            return Ok(true);
1077        }
1078
1079        if self.config.max_nodes > 0 && self.peers.len() as u32 >= self.config.max_nodes {
1080            return Err(ClusterError::ClusterFull {
1081                max: self.config.max_nodes,
1082            });
1083        }
1084
1085        let platform = match platform {
1086            Some("edge") => NodePlatform::Edge,
1087            Some("browser") => NodePlatform::Browser,
1088            Some("wasi") => NodePlatform::Wasi,
1089            Some(other) if other.starts_with("custom:") => {
1090                NodePlatform::Custom(other.trim_start_matches("custom:").to_owned())
1091            }
1092            Some(other) if other != "cloud-native" && other != "linux" => {
1093                NodePlatform::Custom(other.to_owned())
1094            }
1095            _ => NodePlatform::CloudNative,
1096        };
1097
1098        let now = Utc::now();
1099        let peer = PeerNode {
1100            id: node_id.to_owned(),
1101            name: node_id.to_owned(),
1102            platform,
1103            state: if verified { NodeState::Active } else { NodeState::Unverified },
1104            address: address.map(|s| s.to_owned()),
1105            first_seen: now,
1106            last_heartbeat: now,
1107            capabilities: vec!["mesh".into()],
1108            labels: HashMap::from([("source".into(), "mesh".into())]),
1109        };
1110
1111        debug!(node_id = %node_id, "mesh peer join → cluster membership");
1112        self.peers.insert(node_id.to_owned(), peer);
1113        self.persist();
1114        Ok(true)
1115    }
1116
1117    /// Drain a mesh peer-event receiver and apply each event (WEFT-120).
1118    ///
1119    /// Spawns a background task. Intended for boot when `ClusterService`
1120    /// is not available (mesh-only builds) or as a shared membership
1121    /// path. Prefer [`ClusterService::spawn_mesh_peer_listener`] when
1122    /// the cluster feature is on so ruvector nodes stay in sync too.
1123    #[cfg(all(feature = "mesh", feature = "native"))]
1124    pub fn spawn_mesh_peer_listener(
1125        self: &Arc<Self>,
1126        mut rx: tokio::sync::broadcast::Receiver<crate::mesh_discovery::MeshPeerEvent>,
1127    ) {
1128        let membership = Arc::clone(self);
1129        tokio::spawn(async move {
1130            let mut reap = tokio::time::interval(UNVERIFIED_REAP_EVERY);
1131            loop {
1132                let received = tokio::select! {
1133                    _ = reap.tick() => {
1134                        membership.expire_unverified(UNVERIFIED_TTL);
1135                        continue;
1136                    }
1137                    r = rx.recv() => r,
1138                };
1139                match received {
1140                    Ok(event) => {
1141                        if let Err(e) = membership.apply_mesh_peer_event(&event) {
1142                            debug!(
1143                                error = %e,
1144                                node = %event.node_id(),
1145                                "cluster membership: mesh peer event apply failed"
1146                            );
1147                        }
1148                    }
1149                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1150                        warn!(skipped = n, "cluster membership: mesh peer event bus lagged");
1151                    }
1152                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1153                        info!("cluster membership: mesh peer event bus closed");
1154                        break;
1155                    }
1156                }
1157            }
1158        });
1159    }
1160}
1161
1162// ── Time-windowed pairing (Cognitum Seed Gap #2) ────────────────────
1163//
1164// Default: mesh rejects all new peer connections.
1165// `open_pairing_window(duration)` opens a time-limited enrollment window.
1166// During the window, new Noise XX handshakes are accepted.
1167// After the window closes, new peers are rejected.
1168// Paired peers are remembered persistently in `paired_hosts.json`.
1169
1170/// Result returned when a pairing window is opened.
1171#[derive(Debug, Clone, Serialize, Deserialize)]
1172pub struct PairingWindowResult {
1173    /// How long the window will remain open.
1174    pub window_secs: u64,
1175    /// Peers that were paired during previous windows.
1176    pub already_paired: Vec<String>,
1177}
1178
1179/// Persistent record of paired hosts.
1180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct PairedHostsFile {
1182    /// Set of peer IDs that have been paired.
1183    pub hosts: Vec<PairedHost>,
1184}
1185
1186/// A single paired host record.
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct PairedHost {
1189    /// Peer identifier.
1190    pub peer_id: String,
1191    /// When the pairing was established.
1192    pub paired_at: DateTime<Utc>,
1193}
1194
1195/// Manages the pairing window state and persistent paired host list.
1196pub struct PairingGate {
1197    /// When the current window expires (if open).
1198    window_until: std::sync::Mutex<Option<Instant>>,
1199    /// Peers paired during the current open window.
1200    pending_pairs: std::sync::Mutex<Vec<String>>,
1201    /// All persistently paired peer IDs.
1202    paired_hosts: std::sync::Mutex<HashSet<String>>,
1203    /// Path to the paired hosts persistence file.
1204    persist_path: PathBuf,
1205}
1206
1207impl PairingGate {
1208    /// Create a new pairing gate.
1209    ///
1210    /// `persist_path` should point to e.g. `.weftos/runtime/paired_hosts.json`.
1211    pub fn new(persist_path: PathBuf) -> Self {
1212        Self {
1213            window_until: std::sync::Mutex::new(None),
1214            pending_pairs: std::sync::Mutex::new(Vec::new()),
1215            paired_hosts: std::sync::Mutex::new(HashSet::new()),
1216            persist_path,
1217        }
1218    }
1219
1220    /// Load paired hosts from disk. Creates file if missing.
1221    pub fn load(&self) -> Result<usize, std::io::Error> {
1222        if !self.persist_path.exists() {
1223            if let Some(parent) = self.persist_path.parent() {
1224                std::fs::create_dir_all(parent)?;
1225            }
1226            self.save()?;
1227            return Ok(0);
1228        }
1229
1230        let data = std::fs::read_to_string(&self.persist_path)?;
1231        let file: PairedHostsFile = serde_json::from_str(&data)
1232            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1233
1234        let mut hosts = self
1235            .paired_hosts
1236            .lock()
1237            .expect("paired_hosts lock poisoned");
1238        for h in &file.hosts {
1239            hosts.insert(h.peer_id.clone());
1240        }
1241
1242        let count = hosts.len();
1243        info!(count, "loaded paired hosts from disk");
1244        Ok(count)
1245    }
1246
1247    /// Persist paired hosts to disk.
1248    pub fn save(&self) -> Result<(), std::io::Error> {
1249        let hosts = self
1250            .paired_hosts
1251            .lock()
1252            .expect("paired_hosts lock poisoned");
1253        let file = PairedHostsFile {
1254            hosts: hosts
1255                .iter()
1256                .map(|id| PairedHost {
1257                    peer_id: id.clone(),
1258                    paired_at: Utc::now(),
1259                })
1260                .collect(),
1261        };
1262        let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
1263        if let Some(parent) = self.persist_path.parent() {
1264            std::fs::create_dir_all(parent)?;
1265        }
1266        std::fs::write(&self.persist_path, json)
1267    }
1268
1269    /// Open a pairing window for the given duration.
1270    pub fn open_pairing_window(&self, duration: Duration) -> PairingWindowResult {
1271        let mut until = self.window_until.lock().expect("window lock poisoned");
1272        *until = Some(Instant::now() + duration);
1273
1274        let mut pending = self.pending_pairs.lock().expect("pending lock poisoned");
1275        pending.clear();
1276
1277        let already: Vec<String> = self
1278            .paired_hosts
1279            .lock()
1280            .expect("paired_hosts lock poisoned")
1281            .iter()
1282            .cloned()
1283            .collect();
1284
1285        info!(window_secs = duration.as_secs(), "pairing window opened");
1286
1287        PairingWindowResult {
1288            window_secs: duration.as_secs(),
1289            already_paired: already,
1290        }
1291    }
1292
1293    /// Check whether the pairing window is currently open.
1294    pub fn is_pairing_open(&self) -> bool {
1295        let until = self.window_until.lock().expect("window lock poisoned");
1296        match *until {
1297            Some(deadline) => Instant::now() < deadline,
1298            None => false,
1299        }
1300    }
1301
1302    /// Determine whether a peer should be accepted.
1303    ///
1304    /// Returns `true` if:
1305    /// - The peer is already paired, OR
1306    /// - The pairing window is currently open (and records the peer).
1307    pub fn should_accept_peer(&self, peer_id: &str) -> bool {
1308        // Already paired: always accept.
1309        {
1310            let hosts = self
1311                .paired_hosts
1312                .lock()
1313                .expect("paired_hosts lock poisoned");
1314            if hosts.contains(peer_id) {
1315                return true;
1316            }
1317        }
1318
1319        // Check if window is open.
1320        if self.is_pairing_open() {
1321            // Pair this host.
1322            let mut hosts = self
1323                .paired_hosts
1324                .lock()
1325                .expect("paired_hosts lock poisoned");
1326            hosts.insert(peer_id.to_owned());
1327
1328            let mut pending = self.pending_pairs.lock().expect("pending lock poisoned");
1329            pending.push(peer_id.to_owned());
1330
1331            info!(peer_id, "new peer paired during open window");
1332
1333            // Best-effort persist.
1334            drop(hosts);
1335            drop(pending);
1336            if let Err(e) = self.save() {
1337                warn!(error = %e, "failed to persist paired hosts");
1338            }
1339
1340            return true;
1341        }
1342
1343        debug!(
1344            peer_id,
1345            "rejecting peer: pairing window closed and not previously paired"
1346        );
1347        false
1348    }
1349
1350    /// List all persistently paired host IDs.
1351    pub fn paired_hosts(&self) -> Vec<String> {
1352        self.paired_hosts
1353            .lock()
1354            .expect("paired_hosts lock poisoned")
1355            .iter()
1356            .cloned()
1357            .collect()
1358    }
1359
1360    /// Remove a previously paired host. Returns `true` if it was present.
1361    pub fn unpair(&self, peer_id: &str) -> bool {
1362        let mut hosts = self
1363            .paired_hosts
1364            .lock()
1365            .expect("paired_hosts lock poisoned");
1366        let removed = hosts.remove(peer_id);
1367        if removed {
1368            drop(hosts);
1369            if let Err(e) = self.save() {
1370                warn!(error = %e, "failed to persist after unpair");
1371            }
1372            info!(peer_id, "unpaired host");
1373        }
1374        removed
1375    }
1376
1377    /// Return the path used for persistence.
1378    pub fn persist_path(&self) -> &Path {
1379        &self.persist_path
1380    }
1381}
1382
1383impl std::fmt::Debug for PairingGate {
1384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1385        let count = self.paired_hosts.lock().map(|h| h.len()).unwrap_or(0);
1386        f.debug_struct("PairingGate")
1387            .field("open", &self.is_pairing_open())
1388            .field("paired_hosts", &count)
1389            .field("persist_path", &self.persist_path)
1390            .finish()
1391    }
1392}
1393
1394// ── ClusterService (native coordinator layer) ────────────────────────
1395//
1396// Wraps ruvector's ClusterManager behind the `cluster` feature flag.
1397// Only runs on native coordinator nodes; browser/edge nodes participate
1398// through the universal ClusterMembership layer via WebSocket.
1399
1400#[cfg(feature = "cluster")]
1401mod cluster_service {
1402    use std::sync::Arc;
1403    use std::time::Duration;
1404
1405    use async_trait::async_trait;
1406    use tracing::{debug, info};
1407
1408    use ruvector_cluster::{
1409        ClusterConfig as RuvectorClusterConfig, ClusterManager, ClusterNode, DiscoveryService,
1410        NodeStatus, ShardInfo, StaticDiscovery,
1411    };
1412
1413    use crate::cluster::{ClusterMembership, NodePlatform, NodeState, PeerNode};
1414    use crate::health::HealthStatus;
1415    use crate::service::{ServiceType, SystemService};
1416    use clawft_types::config::ClusterNetworkConfig;
1417
1418    /// Native coordinator cluster service.
1419    ///
1420    /// Wraps ruvector's [`ClusterManager`] and syncs discovered nodes
1421    /// into the kernel's universal [`ClusterMembership`] tracker.
1422    pub struct ClusterService {
1423        manager: ClusterManager,
1424        membership: Arc<ClusterMembership>,
1425        config: ClusterNetworkConfig,
1426        /// True once a mesh peer-event listener has been spawned (WEFT-120).
1427        mesh_listener_started: std::sync::atomic::AtomicBool,
1428    }
1429
1430    impl ClusterService {
1431        /// Create a new cluster service.
1432        ///
1433        /// `membership` is the kernel's universal peer tracker that
1434        /// all platforms share. The ClusterService syncs ruvector
1435        /// native node state into it.
1436        pub fn new(
1437            config: ClusterNetworkConfig,
1438            node_id: String,
1439            discovery: Box<dyn DiscoveryService>,
1440            membership: Arc<ClusterMembership>,
1441        ) -> Result<Self, ruvector_cluster::ClusterError> {
1442            let ruvector_config = RuvectorClusterConfig {
1443                replication_factor: config.replication_factor,
1444                shard_count: config.shard_count,
1445                heartbeat_interval: Duration::from_secs(config.heartbeat_interval_secs),
1446                node_timeout: Duration::from_secs(config.node_timeout_secs),
1447                enable_consensus: config.enable_consensus,
1448                min_quorum_size: config.min_quorum_size,
1449            };
1450
1451            let manager = ClusterManager::new(ruvector_config, node_id, discovery)?;
1452
1453            Ok(Self {
1454                manager,
1455                membership,
1456                config,
1457                mesh_listener_started: std::sync::atomic::AtomicBool::new(false),
1458            })
1459        }
1460
1461        /// Create with default config and static (empty) discovery.
1462        pub fn with_defaults(
1463            node_id: String,
1464            membership: Arc<ClusterMembership>,
1465        ) -> Result<Self, ruvector_cluster::ClusterError> {
1466            let config = ClusterNetworkConfig::default();
1467            let discovery = Box::new(StaticDiscovery::new(vec![]));
1468            Self::new(config, node_id, discovery, membership)
1469        }
1470
1471        /// Sync ruvector's native node list into the kernel's
1472        /// [`ClusterMembership`] tracker.
1473        ///
1474        /// Converts ruvector [`ClusterNode`] entries into kernel
1475        /// [`PeerNode`] entries, mapping `SocketAddr` → `String`
1476        /// and `NodeStatus` → `NodeState`.
1477        ///
1478        /// Prefer the mesh peer-event path ([`Self::apply_mesh_peer_event`] /
1479        /// [`Self::spawn_mesh_peer_listener`]) for live membership; this
1480        /// pull-based method remains for bootstrap and fallback.
1481        pub fn sync_to_membership(&self) {
1482            let nodes = self.manager.list_nodes();
1483            for node in &nodes {
1484                let peer = Self::cluster_node_to_peer(node);
1485                if self.membership.get_peer(&peer.id).is_some() {
1486                    // Update existing peer's state
1487                    let new_state = Self::map_status(node.status);
1488                    let _ = self.membership.update_state(&peer.id, new_state);
1489                    let _ = self.membership.heartbeat(&peer.id);
1490                } else {
1491                    // Add new peer
1492                    if let Err(e) = self.membership.add_peer(peer) {
1493                        debug!(error = %e, "failed to sync node to membership");
1494                    }
1495                }
1496            }
1497        }
1498
1499        /// Apply one mesh peer event: membership (atomic) + ruvector manager.
1500        ///
1501        /// Membership is updated synchronously; manager `add_node` /
1502        /// `remove_node` are best-effort fire-and-forget via the runtime
1503        /// (call from async context via [`Self::apply_mesh_peer_event_async`]
1504        /// when manager updates must complete).
1505        #[cfg(feature = "mesh")]
1506        pub fn apply_mesh_peer_event(
1507            &self,
1508            event: &crate::mesh_discovery::MeshPeerEvent,
1509        ) -> Result<bool, crate::cluster::ClusterError> {
1510            self.membership.apply_mesh_peer_event(event)
1511        }
1512
1513        /// Apply mesh peer event and await ruvector manager join/leave.
1514        #[cfg(feature = "mesh")]
1515        pub async fn apply_mesh_peer_event_async(
1516            &self,
1517            event: &crate::mesh_discovery::MeshPeerEvent,
1518        ) -> Result<bool, crate::cluster::ClusterError> {
1519            use crate::mesh_discovery::MeshPeerEvent;
1520            let changed = self.membership.apply_mesh_peer_event(event)?;
1521            match event {
1522                // The ruvector manager only learns of nodes admission verified:
1523                // an unverified claim is not a replication peer.
1524                MeshPeerEvent::Joined {
1525                    node_id,
1526                    address,
1527                    verified: true,
1528                    ..
1529                }
1530                | MeshPeerEvent::Recovered {
1531                    node_id,
1532                    address,
1533                    verified: true,
1534                } => {
1535                    let addr: std::net::SocketAddr = address
1536                        .as_deref()
1537                        .and_then(|s| s.parse().ok())
1538                        .unwrap_or_else(|| "0.0.0.0:0".parse().unwrap());
1539                    let node = ClusterNode::new(node_id.clone(), addr);
1540                    if let Err(e) = self.manager.add_node(node).await {
1541                        debug!(error = %e, node = %node_id, "cluster manager: add_node from mesh");
1542                    }
1543                }
1544                MeshPeerEvent::Left { node_id } | MeshPeerEvent::Unreachable { node_id } => {
1545                    if let Err(e) = self.manager.remove_node(node_id).await {
1546                        debug!(
1547                            error = %e,
1548                            node = %node_id,
1549                            "cluster manager: remove_node from mesh"
1550                        );
1551                    }
1552                }
1553                MeshPeerEvent::Alive { .. }
1554                | MeshPeerEvent::Suspect { .. }
1555                | MeshPeerEvent::Joined { .. }
1556                | MeshPeerEvent::Recovered { .. } => {}
1557            }
1558            Ok(changed)
1559        }
1560
1561        /// Subscribe to the mesh peer-event stream and keep membership +
1562        /// ruvector manager aligned with live mesh state (WEFT-120).
1563        ///
1564        /// Idempotent: only one listener task is spawned per service.
1565        #[cfg(feature = "mesh")]
1566        pub fn spawn_mesh_peer_listener(
1567            self: &Arc<Self>,
1568            mut rx: tokio::sync::broadcast::Receiver<crate::mesh_discovery::MeshPeerEvent>,
1569        ) {
1570            use std::sync::atomic::Ordering;
1571            if self
1572                .mesh_listener_started
1573                .swap(true, Ordering::SeqCst)
1574            {
1575                debug!("cluster service mesh peer listener already started");
1576                return;
1577            }
1578            let svc = Arc::clone(self);
1579            tokio::spawn(async move {
1580                info!("cluster service subscribed to mesh peer-event stream");
1581                let mut reap = tokio::time::interval(super::UNVERIFIED_REAP_EVERY);
1582                loop {
1583                    let received = tokio::select! {
1584                        _ = reap.tick() => {
1585                            svc.membership.expire_unverified(super::UNVERIFIED_TTL);
1586                            continue;
1587                        }
1588                        r = rx.recv() => r,
1589                    };
1590                    match received {
1591                        Ok(event) => {
1592                            if let Err(e) = svc.apply_mesh_peer_event_async(&event).await {
1593                                debug!(
1594                                    error = %e,
1595                                    node = %event.node_id(),
1596                                    "cluster service: mesh peer event apply failed"
1597                                );
1598                            }
1599                        }
1600                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1601                            tracing::warn!(
1602                                skipped = n,
1603                                "cluster service: mesh peer event bus lagged"
1604                            );
1605                        }
1606                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1607                            info!("cluster service: mesh peer event bus closed");
1608                            break;
1609                        }
1610                    }
1611                }
1612            });
1613        }
1614
1615        /// Whether a mesh peer-event listener is attached.
1616        #[cfg(feature = "mesh")]
1617        pub fn mesh_listener_started(&self) -> bool {
1618            self.mesh_listener_started
1619                .load(std::sync::atomic::Ordering::SeqCst)
1620        }
1621
1622        /// Shared membership handle (tests / boot introspection).
1623        pub fn membership(&self) -> &Arc<ClusterMembership> {
1624            &self.membership
1625        }
1626
1627        /// Get the cluster network configuration.
1628        pub fn config(&self) -> &ClusterNetworkConfig {
1629            &self.config
1630        }
1631
1632        /// Get the underlying cluster manager (for advanced operations).
1633        pub fn manager(&self) -> &ClusterManager {
1634            &self.manager
1635        }
1636
1637        /// Get cluster statistics.
1638        pub fn stats(&self) -> ruvector_cluster::ClusterStats {
1639            self.manager.get_stats()
1640        }
1641
1642        /// List all shards.
1643        pub fn list_shards(&self) -> Vec<ShardInfo> {
1644            self.manager.list_shards()
1645        }
1646
1647        /// List all ruvector nodes.
1648        pub fn list_nodes(&self) -> Vec<ClusterNode> {
1649            self.manager.list_nodes()
1650        }
1651
1652        /// Convert a ruvector `NodeStatus` to a kernel `NodeState`.
1653        fn map_status(status: NodeStatus) -> NodeState {
1654            match status {
1655                NodeStatus::Leader | NodeStatus::Follower | NodeStatus::Candidate => {
1656                    NodeState::Active
1657                }
1658                NodeStatus::Offline => NodeState::Unreachable,
1659            }
1660        }
1661
1662        /// Convert a ruvector `ClusterNode` to a kernel `PeerNode`.
1663        fn cluster_node_to_peer(node: &ClusterNode) -> PeerNode {
1664            PeerNode {
1665                id: node.node_id.clone(),
1666                name: node
1667                    .metadata
1668                    .get("name")
1669                    .cloned()
1670                    .unwrap_or_else(|| node.node_id.clone()),
1671                platform: NodePlatform::CloudNative,
1672                state: Self::map_status(node.status),
1673                address: Some(node.address.to_string()),
1674                first_seen: node.last_seen, // best approximation
1675                last_heartbeat: node.last_seen,
1676                capabilities: Vec::new(),
1677                labels: node.metadata.clone(),
1678            }
1679        }
1680    }
1681
1682    #[async_trait]
1683    impl SystemService for ClusterService {
1684        fn name(&self) -> &str {
1685            "cluster"
1686        }
1687
1688        fn service_type(&self) -> ServiceType {
1689            ServiceType::Core
1690        }
1691
1692        async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1693            info!("starting cluster service");
1694
1695            // ruvector's ClusterManager.start() initializes shards by
1696            // hashing each shard_id against the consistent-hash ring;
1697            // an empty ring fails with "No nodes available for shard
1698            // assignment". The manager never auto-registers its own
1699            // node, so a single-node cluster (or a coordinator that
1700            // boots before any peer is discovered) hits the failure
1701            // every time.
1702            //
1703            // Register the local node first so the ring always has at
1704            // least one entry. Address is a synthetic loopback
1705            // placeholder — ruvector itself doesn't dial it, the
1706            // address only matters for cross-node routing once peers
1707            // join, and the kernel's mesh layer is the authoritative
1708            // bind. add_node also internally calls rebalance_shards;
1709            // ignore the rebalance error path because the ring is
1710            // guaranteed non-empty after the insert above.
1711            let local_id = self.membership.local_node_id().to_owned();
1712            let placeholder_addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
1713            let self_node = ClusterNode::new(local_id, placeholder_addr);
1714            if let Err(e) = self.manager.add_node(self_node).await {
1715                debug!(error = %e, "cluster: failed to pre-register self (continuing)");
1716            }
1717
1718            self.manager
1719                .start()
1720                .await
1721                .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
1722            self.sync_to_membership();
1723            info!(
1724                nodes = self.manager.list_nodes().len(),
1725                shards = self.manager.list_shards().len(),
1726                "cluster service started"
1727            );
1728            Ok(())
1729        }
1730
1731        async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1732            info!("stopping cluster service");
1733            // Mark self as leaving in membership
1734            let local_id = self.membership.local_node_id().to_owned();
1735            let _ = self.membership.update_state(&local_id, NodeState::Left);
1736            Ok(())
1737        }
1738
1739        async fn health_check(&self) -> HealthStatus {
1740            let stats = self.manager.get_stats();
1741            if stats.healthy_nodes > 0 {
1742                HealthStatus::Healthy
1743            } else {
1744                HealthStatus::Degraded("no healthy cluster nodes".into())
1745            }
1746        }
1747    }
1748}
1749
1750#[cfg(feature = "cluster")]
1751pub use cluster_service::ClusterService;
1752
1753
1754/// A pre-0.8 UUID peer id (`8-4-4-4-12` hex). Current node ids are 32 lowercase hex.
1755fn is_legacy_uuid_id(id: &str) -> bool {
1756    let parts: Vec<&str> = id.split('-').collect();
1757    parts.len() == 5
1758        && parts.iter().zip([8, 4, 4, 4, 12]).all(|(p, n)| p.len() == n && p.bytes().all(|b| b.is_ascii_hexdigit()))
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763    use super::*;
1764
1765    fn make_peer(id: &str, name: &str) -> PeerNode {
1766        PeerNode {
1767            id: id.into(),
1768            name: name.into(),
1769            platform: NodePlatform::CloudNative,
1770            state: NodeState::Active,
1771            address: Some("10.0.0.1:8080".into()),
1772            first_seen: Utc::now(),
1773            last_heartbeat: Utc::now(),
1774            capabilities: vec!["compute".into()],
1775            labels: HashMap::from([("region".into(), "us-east".into())]),
1776        }
1777    }
1778
1779    #[test]
1780    fn default_config() {
1781        let config = ClusterConfig::default();
1782        assert_eq!(config.node_name, "local");
1783        assert_eq!(config.heartbeat_interval_secs, 5);
1784        assert_eq!(config.suspect_threshold, 3);
1785    }
1786
1787    #[test]
1788    #[cfg(feature = "ecc")]
1789    fn recommended_heartbeat_falls_back_when_no_model() {
1790        // Finding #5: with no model, the recommendation must equal
1791        // the configured heartbeat_interval_secs.
1792        let config = ClusterConfig {
1793            heartbeat_interval_secs: 7,
1794            ..Default::default()
1795        };
1796        assert_eq!(config.recommended_heartbeat_secs(None, 0, 0.0, 0.0), 7);
1797    }
1798
1799    #[test]
1800    #[cfg(feature = "ecc")]
1801    fn recommended_heartbeat_falls_back_when_untrained() {
1802        // Finding #5: an untrained model must not override the config.
1803        let config = ClusterConfig {
1804            heartbeat_interval_secs: 9,
1805            ..Default::default()
1806        };
1807        let model = crate::eml_kernel::GossipTimingModel::new();
1808        assert!(!model.is_trained());
1809        assert_eq!(
1810            config.recommended_heartbeat_secs(Some(&model), 5, 50.0, 1.0),
1811            9,
1812        );
1813    }
1814
1815    #[test]
1816    #[cfg(feature = "ecc")]
1817    fn recommended_heartbeat_uses_trained_model() {
1818        // Finding #5: with a trained model the recommendation comes
1819        // from the model's predict, not the config.
1820        let model = crate::eml_kernel::GossipTimingModel::new();
1821        let mut json = serde_json::to_value(&model).unwrap();
1822        if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
1823            inner.insert("trained".into(), serde_json::Value::Bool(true));
1824        }
1825        let forced: crate::eml_kernel::GossipTimingModel = serde_json::from_value(json).unwrap();
1826        assert!(forced.is_trained());
1827
1828        let config = ClusterConfig {
1829            heartbeat_interval_secs: 5,
1830            ..Default::default()
1831        };
1832        let recommended = config.recommended_heartbeat_secs(Some(&forced), 10, 50.0, 1.0);
1833        // Clamped to [1, 60] by the model.
1834        assert!((1..=60).contains(&recommended));
1835    }
1836
1837    #[test]
1838    fn config_serde_roundtrip() {
1839        let config = ClusterConfig {
1840            node_id: "node-1".into(),
1841            node_name: "primary".into(),
1842            platform: NodePlatform::Edge,
1843            heartbeat_interval_secs: 10,
1844            suspect_threshold: 5,
1845            unreachable_threshold: 15,
1846            max_nodes: 100,
1847            ..Default::default()
1848        };
1849        let json = serde_json::to_string(&config).unwrap();
1850        let restored: ClusterConfig = serde_json::from_str(&json).unwrap();
1851        assert_eq!(restored.node_name, "primary");
1852        assert_eq!(restored.max_nodes, 100);
1853    }
1854
1855    #[test]
1856    fn node_platform_display() {
1857        assert_eq!(NodePlatform::CloudNative.to_string(), "cloud-native");
1858        assert_eq!(NodePlatform::Browser.to_string(), "browser");
1859        assert_eq!(
1860            NodePlatform::Custom("k8s".into()).to_string(),
1861            "custom(k8s)"
1862        );
1863    }
1864
1865    #[test]
1866    fn node_state_display() {
1867        assert_eq!(NodeState::Active.to_string(), "active");
1868        assert_eq!(NodeState::Suspect.to_string(), "suspect");
1869        assert_eq!(NodeState::Unreachable.to_string(), "unreachable");
1870    }
1871
1872    /// Helper: create a ClusterMembership with rate limiting disabled for tests.
1873    fn make_cluster(config: ClusterConfig) -> ClusterMembership {
1874        ClusterMembership::new(config).with_min_peer_interval(std::time::Duration::ZERO)
1875    }
1876
1877    #[test]
1878    fn add_and_list_peers() {
1879        let cluster = make_cluster(ClusterConfig::default());
1880        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1881        cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1882
1883        let peers = cluster.list_peers();
1884        assert_eq!(peers.len(), 2);
1885    }
1886
1887    #[test]
1888    fn add_duplicate_fails() {
1889        let cluster = make_cluster(ClusterConfig::default());
1890        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1891        assert!(matches!(
1892            cluster.add_peer(make_peer("node-1", "alpha-dup")),
1893            Err(ClusterError::NodeAlreadyExists { .. })
1894        ));
1895    }
1896
1897    #[test]
1898    fn cluster_full() {
1899        let config = ClusterConfig {
1900            max_nodes: 1,
1901            ..Default::default()
1902        };
1903        let cluster = make_cluster(config);
1904        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1905        assert!(matches!(
1906            cluster.add_peer(make_peer("node-2", "beta")),
1907            Err(ClusterError::ClusterFull { .. })
1908        ));
1909    }
1910
1911    #[test]
1912    fn remove_peer() {
1913        let cluster = make_cluster(ClusterConfig::default());
1914        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1915        let removed = cluster.remove_peer("node-1").unwrap();
1916        assert_eq!(removed.name, "alpha");
1917        assert!(cluster.is_empty());
1918    }
1919
1920    #[test]
1921    fn remove_nonexistent_fails() {
1922        let cluster = make_cluster(ClusterConfig::default());
1923        assert!(matches!(
1924            cluster.remove_peer("nope"),
1925            Err(ClusterError::NodeNotFound { .. })
1926        ));
1927    }
1928
1929    #[test]
1930    fn update_state() {
1931        let cluster = make_cluster(ClusterConfig::default());
1932        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1933        cluster.update_state("node-1", NodeState::Suspect).unwrap();
1934        let peer = cluster.get_peer("node-1").unwrap();
1935        assert_eq!(peer.state, NodeState::Suspect);
1936    }
1937
1938    #[test]
1939    fn heartbeat_clears_suspect() {
1940        let cluster = make_cluster(ClusterConfig::default());
1941        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1942        cluster.update_state("node-1", NodeState::Suspect).unwrap();
1943        cluster.heartbeat("node-1").unwrap();
1944        let peer = cluster.get_peer("node-1").unwrap();
1945        assert_eq!(peer.state, NodeState::Active);
1946    }
1947
1948    #[test]
1949    fn count_by_state() {
1950        let cluster = make_cluster(ClusterConfig::default());
1951        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1952        cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1953        cluster.update_state("node-2", NodeState::Suspect).unwrap();
1954        assert_eq!(cluster.count_by_state(&NodeState::Active), 1);
1955        assert_eq!(cluster.count_by_state(&NodeState::Suspect), 1);
1956    }
1957
1958    #[test]
1959    fn active_peers() {
1960        let cluster = make_cluster(ClusterConfig::default());
1961        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1962        cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1963        cluster.update_state("node-2", NodeState::Leaving).unwrap();
1964        let active = cluster.active_peers();
1965        assert_eq!(active.len(), 1);
1966        assert_eq!(active[0], "node-1");
1967    }
1968
1969    #[test]
1970    fn peer_serde_roundtrip() {
1971        let peer = make_peer("node-1", "alpha");
1972        let json = serde_json::to_string(&peer).unwrap();
1973        let restored: PeerNode = serde_json::from_str(&json).unwrap();
1974        assert_eq!(restored.id, "node-1");
1975        assert_eq!(restored.capabilities, vec!["compute"]);
1976    }
1977
1978    #[test]
1979    fn cluster_error_display() {
1980        let err = ClusterError::NodeNotFound {
1981            node_id: "node-1".into(),
1982        };
1983        assert!(err.to_string().contains("node-1"));
1984
1985        let err = ClusterError::ClusterFull { max: 10 };
1986        assert!(err.to_string().contains("10"));
1987    }
1988
1989    #[test]
1990    fn mesh_and_auth_error_display() {
1991        let err = ClusterError::Mesh("connection refused".into());
1992        assert!(err.to_string().contains("connection refused"));
1993
1994        let err = ClusterError::AuthFailed("bad signature".into());
1995        assert!(err.to_string().contains("bad signature"));
1996    }
1997
1998    #[test]
1999    fn default_config_new_fields() {
2000        let config = ClusterConfig::default();
2001        assert!(config.bind_address.is_none());
2002        assert!(config.seed_peers.is_empty());
2003        assert!(config.identity_key_path.is_none());
2004        // WEFT-148 defaults
2005        assert_eq!(config.peer_add_max_per_source, 1);
2006        assert_eq!(config.peer_add_window_ms, 100);
2007        assert!(config.peer_add_rate_config().is_enabled());
2008    }
2009
2010    #[test]
2011    fn rate_limited_peer_additions() {
2012        // Use the default per-source rate limit (do NOT use make_cluster here).
2013        // make_peer assigns the same address → same source key.
2014        let cluster = ClusterMembership::new(ClusterConfig::default());
2015        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2016
2017        // Second add from the same source immediately should be rate limited.
2018        let result = cluster.add_peer(make_peer("node-2", "beta"));
2019        assert!(
2020            matches!(result, Err(ClusterError::RateLimited)),
2021            "expected RateLimited, got {result:?}"
2022        );
2023    }
2024
2025    #[test]
2026    fn rate_limit_is_per_source_not_global() {
2027        // Different addresses are independent sources (WEFT-148).
2028        let cluster = ClusterMembership::new(ClusterConfig::default());
2029        let mut a = make_peer("node-1", "alpha");
2030        a.address = Some("10.0.0.1:8080".into());
2031        let mut b = make_peer("node-2", "beta");
2032        b.address = Some("10.0.0.2:8080".into());
2033
2034        cluster.add_peer(a).unwrap();
2035        // Different source must still succeed under default 1/100ms limit.
2036        cluster.add_peer(b).unwrap();
2037        assert_eq!(cluster.len(), 2);
2038    }
2039
2040    #[test]
2041    fn rate_limit_configurable_threshold() {
2042        // Allow 2 adds per source per long window.
2043        let cluster = ClusterMembership::new(ClusterConfig::default())
2044            .with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig {
2045                max_per_window: 2,
2046                window: Duration::from_secs(60),
2047            });
2048        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2049        cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2050        let third = cluster.add_peer(make_peer("node-3", "gamma"));
2051        assert!(
2052            matches!(third, Err(ClusterError::RateLimited)),
2053            "expected RateLimited on third same-source add, got {third:?}"
2054        );
2055    }
2056
2057    #[test]
2058    fn rate_limit_source_key_prefers_address() {
2059        let mut peer = make_peer("node-1", "alpha");
2060        peer.address = Some("192.168.1.5:9489".into());
2061        assert_eq!(
2062            ClusterMembership::peer_add_source_key(&peer),
2063            "192.168.1.5:9489"
2064        );
2065        peer.address = None;
2066        assert_eq!(ClusterMembership::peer_add_source_key(&peer), "node-1");
2067        peer.address = Some(String::new());
2068        assert_eq!(ClusterMembership::peer_add_source_key(&peer), "node-1");
2069    }
2070
2071    #[test]
2072    fn rate_limited_error_display() {
2073        let err = ClusterError::RateLimited;
2074        assert!(err.to_string().contains("rate limited"));
2075    }
2076
2077    #[test]
2078    fn validate_known_capabilities() {
2079        let known = vec!["ipc".into(), "mesh".into(), "chain".into()];
2080        let unknown = validate_capabilities(&known);
2081        assert!(unknown.is_empty());
2082    }
2083
2084    #[test]
2085    fn validate_unknown_capabilities() {
2086        let caps = vec!["ipc".into(), "teleport".into(), "quantum".into()];
2087        let unknown = validate_capabilities(&caps);
2088        assert_eq!(unknown, vec!["teleport", "quantum"]);
2089    }
2090
2091    #[test]
2092    fn add_peer_with_unknown_capabilities_succeeds() {
2093        let cluster = make_cluster(ClusterConfig::default());
2094        let mut peer = make_peer("node-1", "alpha");
2095        peer.capabilities = vec!["ipc".into(), "teleport".into()];
2096        // Should succeed (warning logged, but not rejected).
2097        cluster.add_peer(peer).unwrap();
2098        assert_eq!(cluster.len(), 1);
2099    }
2100
2101    #[test]
2102    fn add_peer_checked_allows_clean_host() {
2103        let cluster = make_cluster(ClusterConfig::default());
2104        let dir = tempfile::tempdir().unwrap();
2105        let list = crate::revocation::RevocationList::new(dir.path().join("r.json"));
2106        let peer = make_peer("node-1", "alpha");
2107        assert!(cluster.add_peer_checked(peer, &list).is_ok());
2108        assert_eq!(cluster.len(), 1);
2109    }
2110
2111    #[test]
2112    fn add_peer_checked_rejects_revoked_host() {
2113        let cluster = make_cluster(ClusterConfig::default());
2114        let dir = tempfile::tempdir().unwrap();
2115        let list = crate::revocation::RevocationList::new(dir.path().join("r.json"));
2116        list.revoke_host("node-1", "banned");
2117        let peer = make_peer("node-1", "alpha");
2118        let result = cluster.add_peer_checked(peer, &list);
2119        assert!(matches!(result, Err(ClusterError::AuthFailed(_))));
2120        assert_eq!(cluster.len(), 0);
2121    }
2122
2123    // ── PairingGate tests ───────────────────────────────────────────
2124
2125    fn pairing_tmp_path(name: &str) -> std::path::PathBuf {
2126        std::env::temp_dir().join(format!(
2127            "pairing_test_{name}_{}",
2128            std::time::SystemTime::now()
2129                .duration_since(std::time::UNIX_EPOCH)
2130                .unwrap()
2131                .as_nanos()
2132        ))
2133    }
2134
2135    fn cleanup_pairing(path: &std::path::Path) {
2136        if let Some(parent) = path.parent() {
2137            let _ = std::fs::remove_dir_all(parent);
2138        }
2139    }
2140
2141    #[test]
2142    fn pairing_gate_default_closed() {
2143        let path = pairing_tmp_path("default_closed").join("paired_hosts.json");
2144        let gate = PairingGate::new(path.clone());
2145        assert!(!gate.is_pairing_open());
2146        assert!(!gate.should_accept_peer("unknown-peer"));
2147        cleanup_pairing(&path);
2148    }
2149
2150    #[test]
2151    fn pairing_gate_open_window_accepts() {
2152        let path = pairing_tmp_path("open_window").join("paired_hosts.json");
2153        let gate = PairingGate::new(path.clone());
2154
2155        let result = gate.open_pairing_window(Duration::from_secs(30));
2156        assert_eq!(result.window_secs, 30);
2157        assert!(result.already_paired.is_empty());
2158        assert!(gate.is_pairing_open());
2159
2160        // Should accept new peers during window.
2161        assert!(gate.should_accept_peer("peer-1"));
2162        assert!(gate.should_accept_peer("peer-2"));
2163
2164        // Paired hosts should now include them.
2165        let hosts = gate.paired_hosts();
2166        assert!(hosts.contains(&"peer-1".to_owned()));
2167        assert!(hosts.contains(&"peer-2".to_owned()));
2168
2169        cleanup_pairing(&path);
2170    }
2171
2172    #[test]
2173    fn pairing_gate_already_paired_always_accepted() {
2174        let path = pairing_tmp_path("already_paired").join("paired_hosts.json");
2175        let gate = PairingGate::new(path.clone());
2176
2177        // Open window and pair a host.
2178        gate.open_pairing_window(Duration::from_secs(30));
2179        assert!(gate.should_accept_peer("peer-1"));
2180
2181        // Close window by setting it to zero duration.
2182        gate.open_pairing_window(Duration::from_secs(0));
2183        std::thread::sleep(Duration::from_millis(10));
2184        assert!(!gate.is_pairing_open());
2185
2186        // Already paired host should still be accepted.
2187        assert!(gate.should_accept_peer("peer-1"));
2188
2189        // Unknown host should be rejected (window closed).
2190        assert!(!gate.should_accept_peer("unknown"));
2191
2192        cleanup_pairing(&path);
2193    }
2194
2195    #[test]
2196    fn pairing_gate_unpair() {
2197        let path = pairing_tmp_path("unpair").join("paired_hosts.json");
2198        let gate = PairingGate::new(path.clone());
2199
2200        gate.open_pairing_window(Duration::from_secs(30));
2201        gate.should_accept_peer("peer-1");
2202
2203        assert!(gate.unpair("peer-1"));
2204        assert!(!gate.unpair("peer-1")); // already removed
2205
2206        cleanup_pairing(&path);
2207    }
2208
2209    #[test]
2210    fn pairing_gate_persist_and_load() {
2211        let dir = pairing_tmp_path("persist_load");
2212        let path = dir.join("paired_hosts.json");
2213
2214        // Create and pair.
2215        {
2216            let gate = PairingGate::new(path.clone());
2217            gate.open_pairing_window(Duration::from_secs(30));
2218            gate.should_accept_peer("peer-a");
2219            gate.should_accept_peer("peer-b");
2220            gate.save().unwrap();
2221        }
2222
2223        // Load into new gate.
2224        {
2225            let gate = PairingGate::new(path.clone());
2226            let count = gate.load().unwrap();
2227            assert_eq!(count, 2);
2228            assert!(gate.paired_hosts().contains(&"peer-a".to_owned()));
2229            assert!(gate.paired_hosts().contains(&"peer-b".to_owned()));
2230        }
2231
2232        let _ = std::fs::remove_dir_all(&dir);
2233    }
2234
2235    #[test]
2236    fn pairing_gate_load_creates_file_if_missing() {
2237        let dir = pairing_tmp_path("load_creates");
2238        let path = dir.join("runtime").join("paired_hosts.json");
2239
2240        let gate = PairingGate::new(path.clone());
2241        let count = gate.load().unwrap();
2242        assert_eq!(count, 0);
2243        assert!(path.exists());
2244
2245        let _ = std::fs::remove_dir_all(&dir);
2246    }
2247
2248    #[test]
2249    fn pairing_window_result_serde() {
2250        let result = PairingWindowResult {
2251            window_secs: 30,
2252            already_paired: vec!["peer-1".into()],
2253        };
2254        let json = serde_json::to_string(&result).unwrap();
2255        let restored: PairingWindowResult = serde_json::from_str(&json).unwrap();
2256        assert_eq!(restored.window_secs, 30);
2257        assert_eq!(restored.already_paired, vec!["peer-1"]);
2258    }
2259
2260    fn persist_tmp_path(suffix: &str) -> PathBuf {
2261        let pid = std::process::id();
2262        let ns = std::time::SystemTime::now()
2263            .duration_since(std::time::UNIX_EPOCH)
2264            .unwrap()
2265            .as_nanos();
2266        let dir = std::env::temp_dir().join(format!("weftos-cluster-{pid}-{ns}-{suffix}"));
2267        std::fs::create_dir_all(&dir).unwrap();
2268        dir.join("cluster_peers.json")
2269    }
2270
2271    #[test]
2272    fn persisted_peers_rehydrate_on_restart() {
2273        let path = persist_tmp_path("rehydrate");
2274
2275        {
2276            let cluster = ClusterMembership::new(ClusterConfig::default())
2277                .with_min_peer_interval(std::time::Duration::ZERO)
2278                .with_persist_path(&path);
2279            cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2280            cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2281            assert_eq!(cluster.len(), 2);
2282        }
2283
2284        assert!(path.exists(), "persist path should exist after add_peer");
2285
2286        let restored = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2287        assert_eq!(restored.len(), 2, "peers should rehydrate from disk");
2288        assert!(restored.get_peer("node-1").is_some());
2289        assert!(restored.get_peer("node-2").is_some());
2290
2291        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2292    }
2293
2294    #[test]
2295    fn legacy_uuid_peer_ids_are_dropped_on_load_and_from_disk() {
2296        let path = persist_tmp_path("legacy-uuid");
2297        let uuid = "e6182c9e-2dc9-45c3-a93f-e93d978e3dca";
2298        let hex = "c12684d4ee48f647f0468b233a24b2c2";
2299        {
2300            let cluster = ClusterMembership::new(ClusterConfig::default())
2301                .with_min_peer_interval(std::time::Duration::ZERO)
2302                .with_persist_path(&path);
2303            for id in [uuid, hex, "node-1"] {
2304                cluster.add_peer(make_peer(id, id)).unwrap();
2305            }
2306        }
2307        let restored = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2308        assert!(restored.get_peer(uuid).is_none(), "the UUID entry is dropped");
2309        assert!(restored.get_peer(hex).is_some() && restored.get_peer("node-1").is_some(), "other ids are kept");
2310        let on_disk = std::fs::read_to_string(&path).unwrap();
2311        assert!(!on_disk.contains(uuid), "and it is gone from the file");
2312        assert!(is_legacy_uuid_id(uuid) && !is_legacy_uuid_id(hex) && !is_legacy_uuid_id("node-1"));
2313        assert!(!is_legacy_uuid_id("e6182c9e-2dc9-45c3-a93f-e93d978e3dcz"), "non-hex is not a UUID");
2314        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2315    }
2316
2317    #[test]
2318    fn persist_reflects_remove_and_state_change() {
2319        let path = persist_tmp_path("mutations");
2320
2321        let cluster = ClusterMembership::new(ClusterConfig::default())
2322            .with_min_peer_interval(std::time::Duration::ZERO)
2323            .with_persist_path(&path);
2324        cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2325        cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2326        cluster.update_state("node-1", NodeState::Suspect).unwrap();
2327        cluster.remove_peer("node-2").unwrap();
2328
2329        let data = std::fs::read_to_string(&path).unwrap();
2330        let file: ClusterPeersFile = serde_json::from_str(&data).unwrap();
2331        assert_eq!(file.peers.len(), 1);
2332        assert_eq!(file.peers[0].id, "node-1");
2333        assert_eq!(file.peers[0].state, NodeState::Suspect);
2334
2335        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2336    }
2337
2338    #[test]
2339    fn with_persist_path_missing_file_is_ok() {
2340        let path = persist_tmp_path("missing")
2341            .parent()
2342            .unwrap()
2343            .join("nope.json");
2344        let cluster = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2345        assert_eq!(cluster.len(), 0);
2346
2347        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2348    }
2349
2350    // ── WEFT-120: mesh peer-event → membership ────────────────────
2351
2352    #[cfg(feature = "mesh")]
2353    mod mesh_peer_events {
2354        use super::*;
2355        use crate::mesh_discovery::MeshPeerEvent;
2356
2357        fn joined(id: &str, verified: bool) -> MeshPeerEvent {
2358            MeshPeerEvent::Joined {
2359                node_id: id.into(),
2360                address: Some("10.0.0.6:9489".into()),
2361                platform: None,
2362                verified,
2363            }
2364        }
2365
2366        #[test]
2367        fn an_unverified_join_is_held_unverified_not_active() {
2368            let cluster = make_cluster(ClusterConfig::default());
2369            assert!(cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap());
2370            assert_eq!(cluster.get_peer("claimed").unwrap().state, NodeState::Unverified);
2371            assert!(cluster.active_peers().is_empty(), "an unverified claim is not a member");
2372            // Its route stays: it is listed, with its address.
2373            assert_eq!(cluster.get_peer("claimed").unwrap().address.as_deref(), Some("10.0.0.6:9489"));
2374            assert_eq!(cluster.count_by_state(&NodeState::Unverified), 1);
2375        }
2376
2377        #[test]
2378        fn an_unverified_announce_cannot_refresh_last_seen_or_move_a_verified_peers_address() {
2379            let cluster = make_cluster(ClusterConfig::default());
2380            cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2381            let old = chrono::DateTime::from_timestamp(1_000_000, 0).unwrap();
2382            cluster.peers.get_mut("real").unwrap().last_heartbeat = old;
2383            let spoof = MeshPeerEvent::Joined {
2384                node_id: "real".into(),
2385                address: Some("6.6.6.6:1".into()),
2386                platform: None,
2387                verified: false,
2388            };
2389            cluster.apply_mesh_peer_event(&spoof).unwrap();
2390            let p = cluster.get_peer("real").unwrap();
2391            assert_eq!(p.state, NodeState::Active);
2392            assert_eq!(p.last_heartbeat, old);
2393            assert_eq!(p.address.as_deref(), Some("10.0.0.6:9489"));
2394            // A verified announce does refresh it.
2395            cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2396            assert!(cluster.get_peer("real").unwrap().last_heartbeat > old);
2397        }
2398
2399        #[test]
2400        fn an_alive_event_for_an_unverified_id_gets_no_liveness_credit() {
2401            let cluster = make_cluster(ClusterConfig::default());
2402            cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2403            let old = chrono::DateTime::from_timestamp(1_000_000, 0).unwrap();
2404            cluster.peers.get_mut("claimed").unwrap().last_heartbeat = old;
2405            assert!(!cluster
2406                .apply_mesh_peer_event(&MeshPeerEvent::Alive { node_id: "claimed".into() })
2407                .unwrap());
2408            assert_eq!(cluster.get_peer("claimed").unwrap().last_heartbeat, old);
2409            // A verified peer's Alive still counts.
2410            cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2411            cluster.peers.get_mut("real").unwrap().last_heartbeat = old;
2412            cluster.apply_mesh_peer_event(&MeshPeerEvent::Alive { node_id: "real".into() }).unwrap();
2413            assert!(cluster.get_peer("real").unwrap().last_heartbeat > old);
2414        }
2415
2416        #[test]
2417        fn heartbeats_and_health_events_never_promote_an_unverified_peer() {
2418            let cluster = make_cluster(ClusterConfig::default());
2419            cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2420            for ev in [
2421                MeshPeerEvent::Alive { node_id: "claimed".into() },
2422                MeshPeerEvent::Suspect { node_id: "claimed".into() },
2423                MeshPeerEvent::Alive { node_id: "claimed".into() },
2424                MeshPeerEvent::Recovered {
2425                    node_id: "claimed".into(),
2426                    address: None,
2427                    verified: false,
2428                },
2429            ] {
2430                cluster.apply_mesh_peer_event(&ev).unwrap();
2431                assert_eq!(cluster.get_peer("claimed").unwrap().state, NodeState::Unverified, "{ev:?}");
2432            }
2433            // It still goes when its connection does.
2434            cluster
2435                .apply_mesh_peer_event(&MeshPeerEvent::Left { node_id: "claimed".into() })
2436                .unwrap();
2437            assert!(cluster.get_peer("claimed").is_none());
2438        }
2439
2440        #[test]
2441        fn only_a_verified_join_makes_an_unverified_peer_active() {
2442            let cluster = make_cluster(ClusterConfig::default());
2443            cluster.apply_mesh_peer_event(&joined("p", false)).unwrap();
2444            cluster.apply_mesh_peer_event(&joined("p", true)).unwrap();
2445            assert_eq!(cluster.get_peer("p").unwrap().state, NodeState::Active);
2446            assert_eq!(cluster.active_peers(), vec!["p".to_string()]);
2447        }
2448
2449        #[test]
2450        fn an_unverified_event_does_not_demote_an_active_peer() {
2451            // An operator-added (or earlier verified) peer keeps its state when
2452            // a legacy connection for the same id comes and goes.
2453            let cluster = make_cluster(ClusterConfig::default());
2454            cluster.apply_mesh_peer_event(&joined("op", true)).unwrap();
2455            cluster.apply_mesh_peer_event(&joined("op", false)).unwrap();
2456            assert_eq!(cluster.get_peer("op").unwrap().state, NodeState::Active);
2457            // A down peer that comes back unverified is not Active either.
2458            cluster.update_state("op", NodeState::Unreachable).unwrap();
2459            cluster.apply_mesh_peer_event(&joined("op", false)).unwrap();
2460            assert_eq!(cluster.get_peer("op").unwrap().state, NodeState::Unverified);
2461        }
2462
2463        #[test]
2464        fn an_unreachable_unverified_peer_is_dropped_not_left_listed() {
2465            let cluster = make_cluster(ClusterConfig::default());
2466            cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2467            cluster
2468                .apply_mesh_peer_event(&MeshPeerEvent::Unreachable { node_id: "claimed".into() })
2469                .unwrap();
2470            assert!(cluster.get_peer("claimed").is_none());
2471            // A member that goes unreachable is still kept (as Unreachable).
2472            cluster.apply_mesh_peer_event(&joined("member", true)).unwrap();
2473            cluster
2474                .apply_mesh_peer_event(&MeshPeerEvent::Unreachable { node_id: "member".into() })
2475                .unwrap();
2476            assert_eq!(cluster.get_peer("member").unwrap().state, NodeState::Unreachable);
2477        }
2478
2479        #[test]
2480        fn the_reaper_expires_only_stale_unverified_peers() {
2481            let cluster = make_cluster(ClusterConfig::default());
2482            cluster.apply_mesh_peer_event(&joined("old-claim", false)).unwrap();
2483            cluster.apply_mesh_peer_event(&joined("fresh-claim", false)).unwrap();
2484            cluster.apply_mesh_peer_event(&joined("old-member", true)).unwrap();
2485            let long_ago = Utc::now() - chrono::Duration::hours(3);
2486            for id in ["old-claim", "old-member"] {
2487                cluster.peers.get_mut(id).unwrap().last_heartbeat = long_ago;
2488            }
2489            assert_eq!(cluster.expire_unverified(UNVERIFIED_TTL), 1);
2490            assert!(cluster.get_peer("old-claim").is_none());
2491            assert!(cluster.get_peer("fresh-claim").is_some(), "recently heard from");
2492            assert!(cluster.get_peer("old-member").is_some(), "members are not the reaper's");
2493        }
2494
2495        #[test]
2496        fn an_older_event_without_the_flag_reads_as_unverified() {
2497            let ev: MeshPeerEvent =
2498                serde_json::from_str(r#"{"Joined":{"node_id":"n","address":null,"platform":null}}"#).unwrap();
2499            assert!(matches!(ev, MeshPeerEvent::Joined { verified: false, .. }));
2500        }
2501
2502        #[cfg(feature = "cluster")]
2503        #[tokio::test]
2504        async fn the_ruvector_manager_only_learns_of_verified_nodes() {
2505            use crate::cluster::ClusterService;
2506            use clawft_types::config::ClusterNetworkConfig;
2507            use ruvector_cluster::StaticDiscovery;
2508
2509            let membership = Arc::new(make_cluster(ClusterConfig {
2510                node_id: "local".into(),
2511                ..Default::default()
2512            }));
2513            let svc = Arc::new(
2514                ClusterService::new(
2515                    ClusterNetworkConfig::default(),
2516                    "local".into(),
2517                    Box::new(StaticDiscovery::new(vec![])),
2518                    Arc::clone(&membership),
2519                )
2520                .expect("cluster service"),
2521            );
2522            svc.apply_mesh_peer_event_async(&joined("claimed", false)).await.unwrap();
2523            assert!(membership.get_peer("claimed").is_some());
2524            assert!(!svc.list_nodes().iter().any(|n| n.node_id == "claimed"));
2525            svc.apply_mesh_peer_event_async(&joined("real", true)).await.unwrap();
2526            assert!(svc.list_nodes().iter().any(|n| n.node_id == "real"));
2527        }
2528
2529        #[test]
2530        fn mesh_join_adds_active_peer() {
2531            let cluster = make_cluster(ClusterConfig::default());
2532            let changed = cluster
2533                .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2534                    node_id: "peer-a".into(),
2535                    address: Some("10.0.0.5:9489".into()),
2536                    platform: Some("edge".into()),
2537                    verified: true,
2538                })
2539                .unwrap();
2540            assert!(changed);
2541            let peer = cluster.get_peer("peer-a").expect("joined peer");
2542            assert_eq!(peer.state, NodeState::Active);
2543            assert_eq!(peer.address.as_deref(), Some("10.0.0.5:9489"));
2544            assert_eq!(peer.platform, NodePlatform::Edge);
2545            assert!(peer.capabilities.contains(&"mesh".into()));
2546        }
2547
2548        #[test]
2549        fn mesh_leave_removes_peer() {
2550            let cluster = make_cluster(ClusterConfig::default());
2551            cluster
2552                .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2553                    node_id: "peer-b".into(),
2554                    address: None,
2555                    platform: None,
2556                    verified: true,
2557                })
2558                .unwrap();
2559            assert_eq!(cluster.len(), 1);
2560
2561            let changed = cluster
2562                .apply_mesh_peer_event(&MeshPeerEvent::Left {
2563                    node_id: "peer-b".into(),
2564                })
2565                .unwrap();
2566            assert!(changed);
2567            assert!(cluster.get_peer("peer-b").is_none());
2568            assert_eq!(cluster.len(), 0);
2569        }
2570
2571        #[test]
2572        fn mesh_partition_marks_unreachable_then_recovery() {
2573            let cluster = make_cluster(ClusterConfig::default());
2574            cluster
2575                .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2576                    node_id: "peer-p".into(),
2577                    address: Some("10.1.0.1:9489".into()),
2578                    platform: None,
2579                    verified: true,
2580                })
2581                .unwrap();
2582
2583            cluster
2584                .apply_mesh_peer_event(&MeshPeerEvent::Unreachable {
2585                    node_id: "peer-p".into(),
2586                })
2587                .unwrap();
2588            assert_eq!(
2589                cluster.get_peer("peer-p").unwrap().state,
2590                NodeState::Unreachable
2591            );
2592
2593            // Partition recovery: peer comes back.
2594            cluster
2595                .apply_mesh_peer_event(&MeshPeerEvent::Recovered {
2596                    node_id: "peer-p".into(),
2597                    address: Some("10.1.0.1:9489".into()),
2598                    verified: true,
2599                })
2600                .unwrap();
2601            let peer = cluster.get_peer("peer-p").unwrap();
2602            assert_eq!(peer.state, NodeState::Active);
2603            assert_eq!(cluster.len(), 1);
2604        }
2605
2606        #[test]
2607        fn mesh_suspect_then_alive() {
2608            let cluster = make_cluster(ClusterConfig::default());
2609            cluster
2610                .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2611                    node_id: "peer-s".into(),
2612                    address: None,
2613                    platform: None,
2614                    verified: true,
2615                })
2616                .unwrap();
2617            cluster
2618                .apply_mesh_peer_event(&MeshPeerEvent::Suspect {
2619                    node_id: "peer-s".into(),
2620                })
2621                .unwrap();
2622            assert_eq!(
2623                cluster.get_peer("peer-s").unwrap().state,
2624                NodeState::Suspect
2625            );
2626            cluster
2627                .apply_mesh_peer_event(&MeshPeerEvent::Alive {
2628                    node_id: "peer-s".into(),
2629                })
2630                .unwrap();
2631            assert_eq!(cluster.get_peer("peer-s").unwrap().state, NodeState::Active);
2632        }
2633
2634        #[test]
2635        fn mesh_join_is_idempotent_upsert() {
2636            let cluster = make_cluster(ClusterConfig::default());
2637            for _ in 0..3 {
2638                cluster
2639                    .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2640                        node_id: "peer-i".into(),
2641                        address: Some("1.2.3.4:9".into()),
2642                        platform: None,
2643                        verified: true,
2644                    })
2645                    .unwrap();
2646            }
2647            assert_eq!(cluster.len(), 1);
2648            assert_eq!(
2649                cluster.get_peer("peer-i").unwrap().state,
2650                NodeState::Active
2651            );
2652        }
2653
2654        #[tokio::test]
2655        async fn mesh_event_bus_drives_membership_listener() {
2656            use crate::mesh_discovery::MeshPeerEventBus;
2657
2658            let cluster = Arc::new(make_cluster(ClusterConfig::default()));
2659            let bus = MeshPeerEventBus::new();
2660            let rx = bus.subscribe();
2661            cluster.spawn_mesh_peer_listener(rx);
2662
2663            bus.emit(MeshPeerEvent::Joined {
2664                node_id: "async-peer".into(),
2665                address: Some("127.0.0.1:1".into()),
2666                platform: None,
2667                verified: true,
2668            });
2669            // Allow the spawned task to apply the event.
2670            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2671            assert!(cluster.get_peer("async-peer").is_some());
2672
2673            bus.emit(MeshPeerEvent::Left {
2674                node_id: "async-peer".into(),
2675            });
2676            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2677            assert!(cluster.get_peer("async-peer").is_none());
2678        }
2679
2680        #[cfg(feature = "cluster")]
2681        #[tokio::test]
2682        async fn cluster_service_applies_mesh_join_leave_async() {
2683            use crate::cluster::ClusterService;
2684            use clawft_types::config::ClusterNetworkConfig;
2685            use ruvector_cluster::StaticDiscovery;
2686
2687            let membership = Arc::new(make_cluster(ClusterConfig {
2688                node_id: "local".into(),
2689                ..Default::default()
2690            }));
2691            let svc = Arc::new(
2692                ClusterService::new(
2693                    ClusterNetworkConfig::default(),
2694                    "local".into(),
2695                    Box::new(StaticDiscovery::new(vec![])),
2696                    Arc::clone(&membership),
2697                )
2698                .expect("cluster service"),
2699            );
2700
2701            svc.apply_mesh_peer_event_async(&MeshPeerEvent::Joined {
2702                node_id: "remote-1".into(),
2703                address: Some("127.0.0.1:9471".into()),
2704                platform: None,
2705                verified: true,
2706            })
2707            .await
2708            .unwrap();
2709            assert!(membership.get_peer("remote-1").is_some());
2710            assert!(svc.list_nodes().iter().any(|n| n.node_id == "remote-1"));
2711
2712            svc.apply_mesh_peer_event_async(&MeshPeerEvent::Left {
2713                node_id: "remote-1".into(),
2714            })
2715            .await
2716            .unwrap();
2717            assert!(membership.get_peer("remote-1").is_none());
2718        }
2719
2720        #[cfg(feature = "cluster")]
2721        #[tokio::test]
2722        async fn cluster_service_subscribes_to_mesh_stream() {
2723            use crate::cluster::ClusterService;
2724            use crate::mesh_discovery::MeshPeerEventBus;
2725            use clawft_types::config::ClusterNetworkConfig;
2726            use ruvector_cluster::StaticDiscovery;
2727
2728            let membership = Arc::new(make_cluster(ClusterConfig {
2729                node_id: "coord".into(),
2730                ..Default::default()
2731            }));
2732            let svc = Arc::new(
2733                ClusterService::new(
2734                    ClusterNetworkConfig::default(),
2735                    "coord".into(),
2736                    Box::new(StaticDiscovery::new(vec![])),
2737                    Arc::clone(&membership),
2738                )
2739                .expect("cluster service"),
2740            );
2741
2742            let bus = MeshPeerEventBus::new();
2743            svc.spawn_mesh_peer_listener(bus.subscribe());
2744            assert!(svc.mesh_listener_started());
2745
2746            bus.emit(MeshPeerEvent::Joined {
2747                node_id: "stream-peer".into(),
2748                address: Some("10.0.0.9:9489".into()),
2749                platform: None,
2750                verified: true,
2751            });
2752            tokio::time::sleep(std::time::Duration::from_millis(80)).await;
2753            assert_eq!(
2754                membership.get_peer("stream-peer").unwrap().state,
2755                NodeState::Active
2756            );
2757
2758            // Partition then recovery via bus.
2759            bus.emit(MeshPeerEvent::Unreachable {
2760                node_id: "stream-peer".into(),
2761            });
2762            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2763            assert_eq!(
2764                membership.get_peer("stream-peer").unwrap().state,
2765                NodeState::Unreachable
2766            );
2767
2768            bus.emit(MeshPeerEvent::Recovered {
2769                node_id: "stream-peer".into(),
2770                address: Some("10.0.0.9:9489".into()),
2771                verified: true,
2772            });
2773            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2774            assert_eq!(
2775                membership.get_peer("stream-peer").unwrap().state,
2776                NodeState::Active
2777            );
2778        }
2779    }
2780}
2781
2782#[cfg(test)]
2783#[cfg(any(feature = "mesh", feature = "exochain"))]
2784mod mesh_tests {
2785    use super::*;
2786
2787    #[test]
2788    fn node_identity_unique_ids() {
2789        let id1 = NodeIdentity::generate();
2790        let id2 = NodeIdentity::generate();
2791        assert_ne!(id1.node_id(), id2.node_id());
2792    }
2793
2794    #[test]
2795    fn node_identity_sign_verify() {
2796        use ed25519_dalek::Verifier;
2797
2798        let identity = NodeIdentity::generate();
2799        let data = b"hello mesh";
2800        let sig = identity.sign(data);
2801        assert!(identity.public_key().verify(data, &sig).is_ok());
2802    }
2803
2804    #[test]
2805    fn node_identity_id_is_32_hex_chars() {
2806        let identity = NodeIdentity::generate();
2807        let nid = identity.node_id();
2808        assert_eq!(nid.len(), 32, "node_id should be 32 hex chars (16 bytes)");
2809        assert!(
2810            nid.chars().all(|c| c.is_ascii_hexdigit()),
2811            "node_id must be hex: {nid}"
2812        );
2813    }
2814}