Skip to main content

ant_node/replication/
types.rs

1//! Core types for the replication subsystem.
2//!
3//! These types represent the state machine states, queue entries, and domain
4//! concepts from the Kademlia-style replication design (see
5//! `docs/REPLICATION_DESIGN.md`).
6
7use std::cmp::Ordering;
8use std::collections::{HashMap, HashSet};
9use std::time::{Duration, Instant};
10
11use serde::{Deserialize, Serialize};
12
13use crate::ant_protocol::XorName;
14use saorsa_core::identity::PeerId;
15
16// ---------------------------------------------------------------------------
17// Verification state machine (Section 8 of REPLICATION_DESIGN.md)
18// ---------------------------------------------------------------------------
19
20/// Verification state machine.
21///
22/// Each unknown key transitions through these states exactly once per offer
23/// lifecycle.  See Section 8 of `REPLICATION_DESIGN.md` for the full
24/// state-transition diagram.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum VerificationState {
27    /// Offer received, not yet processed.
28    OfferReceived,
29    /// Passed admission filter, awaiting quorum / paid-list verification.
30    PendingVerify,
31    /// Presence quorum passed (>= `QuorumNeeded` positives from
32    /// `QuorumTargets`).
33    QuorumVerified,
34    /// Paid-list authorisation succeeded (>= `ConfirmNeeded` confirmations or
35    /// derived from replica majority).
36    PaidListVerified,
37    /// Queued for record fetch.
38    QueuedForFetch,
39    /// Actively fetching from a verified source.
40    Fetching,
41    /// Successfully stored locally.
42    Stored,
43    /// Fetch failed but retryable (alternate sources remain).
44    FetchRetryable,
45    /// Fetch permanently abandoned (terminal failure or no alternate sources).
46    FetchAbandoned,
47    /// Quorum failed definitively (both paid-list and presence impossible this
48    /// round).
49    QuorumFailed,
50    /// Quorum inconclusive (timeout with neither success nor fail-fast).
51    QuorumInconclusive,
52    /// Terminal: quorum abandoned, key forgotten.
53    QuorumAbandoned,
54    /// Terminal: key returned to idle (forgotten, requires new offer to
55    /// re-enter).
56    Idle,
57}
58
59// ---------------------------------------------------------------------------
60// Hint pipeline classification
61// ---------------------------------------------------------------------------
62
63/// Whether a key was admitted via replica hints or paid hints only.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum HintPipeline {
66    /// Key is in the admitted replica-hint pipeline (fetch-eligible).
67    Replica,
68    /// Key is in the paid-hint-only pipeline (`PaidForList` update only, no
69    /// fetch).
70    PaidOnly,
71}
72
73// ---------------------------------------------------------------------------
74// Pending-verification table entry
75// ---------------------------------------------------------------------------
76
77/// Entry in the pending-verification table.
78///
79/// Tracks a single key through the verification FSM, recording which peers
80/// responded and which have been tried for fetch.
81#[derive(Debug, Clone)]
82pub struct VerificationEntry {
83    /// Current state in the verification FSM.
84    pub state: VerificationState,
85    /// Which pipeline admitted this key.
86    pub pipeline: HintPipeline,
87    /// Peers that responded `Present` during verification (verified fetch
88    /// sources).
89    pub verified_sources: Vec<PeerId>,
90    /// Peers already tried for fetch (to avoid retrying the same source).
91    pub tried_sources: HashSet<PeerId>,
92    /// When this entry was created.
93    pub created_at: Instant,
94    /// The peer that originally hinted this key (for source tracking).
95    pub hint_sender: PeerId,
96}
97
98// ---------------------------------------------------------------------------
99// Fetch queue candidate
100// ---------------------------------------------------------------------------
101
102/// A candidate queued for fetch, ordered by relevance (nearest-first).
103///
104/// Implements [`Ord`] with *reversed* distance comparison so that a
105/// [`BinaryHeap`](std::collections::BinaryHeap) (max-heap) dequeues the
106/// nearest key first.
107#[derive(Debug, Clone)]
108pub struct FetchCandidate {
109    /// The key to fetch.
110    pub key: XorName,
111    /// XOR distance from self to key (for priority ordering).
112    pub distance: XorName,
113    /// Verified source peers that responded `Present`.
114    pub sources: Vec<PeerId>,
115}
116
117impl Eq for FetchCandidate {}
118
119impl PartialEq for FetchCandidate {
120    fn eq(&self, other: &Self) -> bool {
121        self.distance == other.distance && self.key == other.key
122    }
123}
124
125impl Ord for FetchCandidate {
126    fn cmp(&self, other: &Self) -> Ordering {
127        // Reverse ordering: smaller distance = higher priority (BinaryHeap is
128        // max-heap).  Tie-break on key for consistency with PartialEq.
129        other
130            .distance
131            .cmp(&self.distance)
132            .then_with(|| self.key.cmp(&other.key))
133    }
134}
135
136impl PartialOrd for FetchCandidate {
137    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
138        Some(self.cmp(other))
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Verification evidence types
144// ---------------------------------------------------------------------------
145
146/// Per-key presence evidence from a verification round.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub enum PresenceEvidence {
149    /// Peer holds the record.
150    Present,
151    /// Peer does not hold the record.
152    Absent,
153    /// Peer did not respond in time (neutral, not negative).
154    Unresolved,
155}
156
157/// Per-key paid-list evidence from a verification round.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159pub enum PaidListEvidence {
160    /// Peer confirms key is in its `PaidForList`.
161    Confirmed,
162    /// Peer says key is NOT in its `PaidForList`.
163    NotFound,
164    /// Peer did not respond in time (neutral).
165    Unresolved,
166}
167
168/// Aggregated verification evidence for a single key from one verification
169/// round.
170#[derive(Debug, Clone)]
171pub struct KeyVerificationEvidence {
172    /// Presence evidence per peer (from `QuorumTargets`).
173    pub presence: HashMap<PeerId, PresenceEvidence>,
174    /// Paid-list evidence per peer (from `PaidTargets`).
175    pub paid_list: HashMap<PeerId, PaidListEvidence>,
176}
177
178// ---------------------------------------------------------------------------
179// Failure evidence (Section 14 — TrustEngine integration)
180// ---------------------------------------------------------------------------
181
182/// Failure evidence types emitted to `TrustEngine` (Section 14).
183#[derive(Debug, Clone)]
184pub enum FailureEvidence {
185    /// Failed fetch attempt from a source peer.
186    ReplicationFailure {
187        /// The peer that failed to serve the record.
188        peer: PeerId,
189        /// The key that could not be fetched.
190        key: XorName,
191    },
192    /// Audit failure with confirmed responsible keys.
193    AuditFailure {
194        /// Unique identifier for the audit challenge.
195        challenge_id: u64,
196        /// The peer that was challenged.
197        challenged_peer: PeerId,
198        /// Keys confirmed as failed.
199        confirmed_failed_keys: Vec<XorName>,
200        /// Why the audit failed.
201        reason: AuditFailureReason,
202    },
203    /// Peer claiming bootstrap past grace period.
204    BootstrapClaimAbuse {
205        /// The offending peer.
206        peer: PeerId,
207        /// When this peer was first seen.
208        first_seen: Instant,
209    },
210}
211
212/// Reason for audit failure.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub enum AuditFailureReason {
215    /// Peer timed out (no response within deadline).
216    Timeout,
217    /// Response was malformed.
218    MalformedResponse,
219    /// One or more per-key digest mismatches.
220    DigestMismatch,
221    /// Key was absent (signalled by sentinel digest).
222    KeyAbsent,
223    /// Peer explicitly rejected the audit challenge.
224    Rejected,
225}
226
227// ---------------------------------------------------------------------------
228// Peer sync tracking
229// ---------------------------------------------------------------------------
230
231/// Record of sync history with a peer, for `RepairOpportunity` tracking.
232#[derive(Debug, Clone)]
233pub struct PeerSyncRecord {
234    /// Last time we successfully synced with this peer.
235    pub last_sync: Option<Instant>,
236    /// Number of full neighbor-sync cycles completed since last sync with this
237    /// peer.
238    pub cycles_since_sync: u32,
239}
240
241impl PeerSyncRecord {
242    /// Whether this peer has had a repair opportunity (synced at least once
243    /// and at least one subsequent cycle has completed).
244    #[must_use]
245    pub fn has_repair_opportunity(&self) -> bool {
246        self.last_sync.is_some() && self.cycles_since_sync >= 1
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Neighbor sync cycle state
252// ---------------------------------------------------------------------------
253
254/// Result of observing a peer's bootstrap claim.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum BootstrapClaimObservation {
257    /// The peer is inside its first and only bootstrap-claim grace window.
258    WithinGrace {
259        /// First time this peer claimed bootstrap status.
260        first_seen: Instant,
261    },
262    /// The peer has continuously claimed bootstrap status past the grace period.
263    PastGrace {
264        /// First time this peer claimed bootstrap status.
265        first_seen: Instant,
266    },
267    /// The peer previously stopped claiming bootstrap and then claimed it again.
268    Repeated {
269        /// First time this peer ever claimed bootstrap status.
270        first_seen: Instant,
271    },
272}
273
274/// Neighbor sync cycle state.
275///
276/// Tracks a deterministic walk through the current close-group snapshot,
277/// per-peer cooldown times, active bootstrap claims, and peers that have already
278/// used their one bootstrap-claim window.
279#[derive(Debug)]
280pub struct NeighborSyncState {
281    /// Deterministic ordering of peers for the current cycle (snapshot).
282    pub order: Vec<PeerId>,
283    /// Current cursor position into `order`.
284    pub cursor: usize,
285    /// Per-peer last successful sync time (for cooldown).
286    pub last_sync_times: HashMap<PeerId, Instant>,
287    /// Active bootstrap claim first-seen timestamps per peer.
288    ///
289    /// Entries are removed when a peer stops claiming bootstrap. The peer
290    /// remains in `bootstrap_claim_history`, so a later claim is repeated-claim
291    /// abuse instead of a fresh grace period.
292    pub bootstrap_claims: HashMap<PeerId, Instant>,
293    /// First-ever bootstrap claim timestamp per peer.
294    ///
295    /// This is retained after active claims are cleared so each peer gets at
296    /// most one bootstrap-claim grace window. Under Sybil attack with many
297    /// distinct peer IDs claiming bootstrap, this map grows unboundedly. In
298    /// practice the trust engine limits Sybil impact before this becomes a
299    /// memory issue.
300    pub bootstrap_claim_history: HashMap<PeerId, Instant>,
301    /// Cursor used by post-cycle pruning to rotate through stored records when
302    /// the per-pass prune-confirmation budget is exhausted.
303    pub prune_cursor: usize,
304}
305
306impl NeighborSyncState {
307    /// Create a new cycle from the given close neighbors.
308    #[must_use]
309    pub fn new_cycle(close_neighbors: Vec<PeerId>) -> Self {
310        Self {
311            order: close_neighbors,
312            cursor: 0,
313            last_sync_times: HashMap::new(),
314            bootstrap_claims: HashMap::new(),
315            bootstrap_claim_history: HashMap::new(),
316            prune_cursor: 0,
317        }
318    }
319
320    /// Observe a peer claiming bootstrap status.
321    ///
322    /// A peer receives one grace window from its first observed bootstrap claim.
323    /// If it later stops claiming bootstrap, callers should clear only the
324    /// active claim with [`Self::clear_active_bootstrap_claim`]. A subsequent
325    /// claim is then reported as [`BootstrapClaimObservation::Repeated`].
326    #[must_use]
327    pub fn observe_bootstrap_claim(
328        &mut self,
329        peer: PeerId,
330        now: Instant,
331        grace_period: Duration,
332    ) -> BootstrapClaimObservation {
333        if let Some(first_seen) = self.bootstrap_claims.get(&peer).copied() {
334            if now.duration_since(first_seen) > grace_period {
335                BootstrapClaimObservation::PastGrace { first_seen }
336            } else {
337                BootstrapClaimObservation::WithinGrace { first_seen }
338            }
339        } else if let Some(first_seen) = self.bootstrap_claim_history.get(&peer).copied() {
340            BootstrapClaimObservation::Repeated { first_seen }
341        } else {
342            self.bootstrap_claims.insert(peer, now);
343            self.bootstrap_claim_history.insert(peer, now);
344            BootstrapClaimObservation::WithinGrace { first_seen: now }
345        }
346    }
347
348    /// Clear the active bootstrap claim for a peer, retaining claim history.
349    pub fn clear_active_bootstrap_claim(&mut self, peer: &PeerId) -> bool {
350        self.bootstrap_claims.remove(peer).is_some()
351    }
352
353    /// Whether the current cycle is complete.
354    #[must_use]
355    pub fn is_cycle_complete(&self) -> bool {
356        self.cursor >= self.order.len()
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Bootstrap drain state (Section 16)
362// ---------------------------------------------------------------------------
363
364/// Bootstrap drain state tracking (Section 16).
365#[derive(Debug)]
366pub struct BootstrapState {
367    /// Whether bootstrap is complete (all peer requests done, queues empty).
368    pub drained: bool,
369    /// Number of bootstrap peer requests still pending.
370    pub pending_peer_requests: usize,
371    /// Keys discovered during bootstrap that are still in the verification /
372    /// fetch pipeline.
373    pub pending_keys: HashSet<XorName>,
374    /// Peers whose last bootstrap admission cycle had one or more hints
375    /// silently dropped at the `pending_verify` capacity bounds. Each entry
376    /// represents "this source still owes us at least one re-hinted key
377    /// after the queues drain". `check_bootstrap_drained` refuses to claim
378    /// the node fully drained while this set is non-empty: a source's
379    /// presence is cleared by its next admission cycle that completes with
380    /// zero capacity rejections (i.e. the source successfully re-delivered
381    /// everything that previously overflowed). Tracking per-source instead
382    /// of a global counter prevents one peer's rejection from being
383    /// "cleared" by an unrelated peer's clean cycle.
384    pub capacity_rejected_sources: HashSet<PeerId>,
385}
386
387impl BootstrapState {
388    /// Create initial bootstrap state.
389    #[must_use]
390    pub fn new() -> Self {
391        Self {
392            drained: false,
393            pending_peer_requests: 0,
394            pending_keys: HashSet::new(),
395            capacity_rejected_sources: HashSet::new(),
396        }
397    }
398
399    /// Check if bootstrap is drained.
400    ///
401    /// Only returns `true` after [`super::bootstrap::check_bootstrap_drained`] or
402    /// [`super::bootstrap::mark_bootstrap_drained`] has explicitly set the flag. A fresh
403    /// `BootstrapState` is NOT drained — the audit loop must wait until
404    /// bootstrap work has actually completed (Invariant 19).
405    #[must_use]
406    pub fn is_drained(&self) -> bool {
407        self.drained
408    }
409
410    /// Remove a key from the bootstrap pending set.
411    ///
412    /// Called when a key terminally leaves the verification/fetch pipeline
413    /// (stored, abandoned, quorum failed, etc.) so the drain check set
414    /// shrinks incrementally rather than being re-scanned in full.
415    pub fn remove_key(&mut self, key: &XorName) {
416        self.pending_keys.remove(key);
417    }
418}
419
420impl Default for BootstrapState {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Tests
428// ---------------------------------------------------------------------------
429
430#[cfg(test)]
431mod tests {
432    use std::collections::BinaryHeap;
433
434    use super::*;
435
436    /// Helper: build a `PeerId` from a single byte (zero-padded to 32 bytes).
437    fn peer_id_from_byte(b: u8) -> PeerId {
438        let mut bytes = [0u8; 32];
439        bytes[0] = b;
440        PeerId::from_bytes(bytes)
441    }
442
443    // -- FetchCandidate ordering -------------------------------------------
444
445    #[test]
446    fn fetch_candidate_nearest_key_has_highest_priority() {
447        let near = FetchCandidate {
448            key: [1u8; 32],
449            distance: [
450                0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
451                0, 0, 0, 0,
452            ],
453            sources: vec![peer_id_from_byte(1)],
454        };
455
456        let far = FetchCandidate {
457            key: [2u8; 32],
458            distance: [
459                0xFF, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
460                0, 0, 0, 0, 0,
461            ],
462            sources: vec![peer_id_from_byte(2)],
463        };
464
465        // In a max-heap the "greatest" element pops first.
466        // Our reversed Ord makes smaller-distance candidates greater.
467        assert!(near > far, "nearer candidate should compare greater");
468
469        let mut heap = BinaryHeap::new();
470        heap.push(far.clone());
471        heap.push(near.clone());
472
473        assert_eq!(heap.len(), 2, "heap should contain both candidates");
474
475        let first = heap.pop();
476        assert!(first.is_some(), "first pop should succeed");
477        assert_eq!(
478            first.map(|c| c.key),
479            Some(near.key),
480            "nearest key should pop first"
481        );
482
483        let second = heap.pop();
484        assert!(second.is_some(), "second pop should succeed");
485        assert_eq!(
486            second.map(|c| c.key),
487            Some(far.key),
488            "farthest key should pop second"
489        );
490    }
491
492    #[test]
493    fn fetch_candidate_same_distance_and_key_is_equal() {
494        let a = FetchCandidate {
495            key: [1u8; 32],
496            distance: [5u8; 32],
497            sources: vec![],
498        };
499
500        let b = FetchCandidate {
501            key: [1u8; 32],
502            distance: [5u8; 32],
503            sources: vec![],
504        };
505
506        assert_eq!(
507            a.cmp(&b),
508            Ordering::Equal,
509            "same distance + same key should yield Equal"
510        );
511        assert_eq!(a, b, "PartialEq must agree with Ord");
512    }
513
514    #[test]
515    fn fetch_candidate_same_distance_different_key_is_deterministic() {
516        let a = FetchCandidate {
517            key: [1u8; 32],
518            distance: [5u8; 32],
519            sources: vec![],
520        };
521
522        let b = FetchCandidate {
523            key: [2u8; 32],
524            distance: [5u8; 32],
525            sources: vec![],
526        };
527
528        assert_ne!(
529            a.cmp(&b),
530            Ordering::Equal,
531            "same distance + different key must not be Equal"
532        );
533        assert_ne!(a, b, "PartialEq must agree with Ord");
534    }
535
536    // -- PeerSyncRecord ----------------------------------------------------
537
538    #[test]
539    fn peer_sync_record_no_sync_yet() {
540        let record = PeerSyncRecord {
541            last_sync: None,
542            cycles_since_sync: 0,
543        };
544        assert!(
545            !record.has_repair_opportunity(),
546            "never-synced peer has no repair opportunity"
547        );
548    }
549
550    #[test]
551    fn peer_sync_record_synced_but_no_cycle() {
552        let record = PeerSyncRecord {
553            last_sync: Some(Instant::now()),
554            cycles_since_sync: 0,
555        };
556        assert!(
557            !record.has_repair_opportunity(),
558            "synced peer with zero subsequent cycles has no repair opportunity"
559        );
560    }
561
562    #[test]
563    fn peer_sync_record_synced_with_cycle() {
564        let record = PeerSyncRecord {
565            last_sync: Some(Instant::now()),
566            cycles_since_sync: 1,
567        };
568        assert!(
569            record.has_repair_opportunity(),
570            "synced peer with >= 1 cycle should have repair opportunity"
571        );
572    }
573
574    #[test]
575    fn peer_sync_record_no_sync_many_cycles() {
576        let record = PeerSyncRecord {
577            last_sync: None,
578            cycles_since_sync: 10,
579        };
580        assert!(
581            !record.has_repair_opportunity(),
582            "never-synced peer has no repair opportunity regardless of cycle count"
583        );
584    }
585
586    // -- NeighborSyncState -------------------------------------------------
587
588    #[test]
589    fn neighbor_sync_empty_cycle_is_immediately_complete() {
590        let state = NeighborSyncState::new_cycle(vec![]);
591        assert!(
592            state.is_cycle_complete(),
593            "empty neighbor list means cycle is complete"
594        );
595    }
596
597    #[test]
598    fn neighbor_sync_new_cycle_not_complete() {
599        let peers = vec![peer_id_from_byte(1), peer_id_from_byte(2)];
600        let state = NeighborSyncState::new_cycle(peers);
601        assert!(
602            !state.is_cycle_complete(),
603            "fresh cycle with peers should not be complete"
604        );
605    }
606
607    #[test]
608    fn neighbor_sync_cycle_completes_when_cursor_reaches_end() {
609        let peers = vec![
610            peer_id_from_byte(1),
611            peer_id_from_byte(2),
612            peer_id_from_byte(3),
613        ];
614        let mut state = NeighborSyncState::new_cycle(peers);
615
616        // Simulate stepping through the cycle.
617        state.cursor = 2;
618        assert!(
619            !state.is_cycle_complete(),
620            "cursor at len-1 should not be complete"
621        );
622
623        state.cursor = 3;
624        assert!(
625            state.is_cycle_complete(),
626            "cursor at len should be complete"
627        );
628    }
629
630    #[test]
631    fn neighbor_sync_cursor_past_end_is_still_complete() {
632        let peers = vec![peer_id_from_byte(1)];
633        let mut state = NeighborSyncState::new_cycle(peers);
634        state.cursor = 5;
635        assert!(
636            state.is_cycle_complete(),
637            "cursor past end should still report complete"
638        );
639    }
640
641    #[test]
642    fn bootstrap_claim_history_prevents_second_grace_window() {
643        let peer = peer_id_from_byte(9);
644        let mut state = NeighborSyncState::new_cycle(vec![peer]);
645        let first_seen = Instant::now();
646        let grace = Duration::from_secs(60);
647
648        assert_eq!(
649            state.observe_bootstrap_claim(peer, first_seen, grace),
650            BootstrapClaimObservation::WithinGrace { first_seen }
651        );
652        assert!(state.clear_active_bootstrap_claim(&peer));
653        assert!(!state.bootstrap_claims.contains_key(&peer));
654        assert!(state.bootstrap_claim_history.contains_key(&peer));
655
656        assert_eq!(
657            state.observe_bootstrap_claim(peer, first_seen + Duration::from_secs(1), grace),
658            BootstrapClaimObservation::Repeated { first_seen }
659        );
660        assert!(
661            !state.bootstrap_claims.contains_key(&peer),
662            "repeated claims must not recreate an active grace window"
663        );
664        assert_eq!(
665            state.observe_bootstrap_claim(peer, first_seen + Duration::from_secs(2), grace),
666            BootstrapClaimObservation::Repeated { first_seen }
667        );
668    }
669
670    #[test]
671    fn bootstrap_claim_active_window_reports_past_grace() {
672        let peer = peer_id_from_byte(10);
673        let mut state = NeighborSyncState::new_cycle(vec![peer]);
674        let first_seen = Instant::now();
675        let grace = Duration::from_secs(60);
676
677        let _ = state.observe_bootstrap_claim(peer, first_seen, grace);
678
679        assert_eq!(
680            state.observe_bootstrap_claim(peer, first_seen + grace + Duration::from_secs(1), grace),
681            BootstrapClaimObservation::PastGrace { first_seen }
682        );
683    }
684
685    // -- BootstrapState ----------------------------------------------------
686
687    #[test]
688    fn bootstrap_state_initial_not_drained() {
689        // A freshly created state must NOT report drained — the bootstrap
690        // sync task has not started yet (Invariant 19 race prevention).
691        let state = BootstrapState::new();
692        assert!(
693            !state.is_drained(),
694            "initial state must not be drained before bootstrap begins"
695        );
696    }
697
698    #[test]
699    fn bootstrap_state_pending_requests_block_drain() {
700        let mut state = BootstrapState::new();
701        state.pending_peer_requests = 3;
702        assert!(
703            !state.is_drained(),
704            "pending peer requests should block drain"
705        );
706    }
707
708    #[test]
709    fn bootstrap_state_pending_keys_block_drain() {
710        let mut state = BootstrapState::new();
711        state.pending_keys.insert([42u8; 32]);
712        assert!(!state.is_drained(), "pending keys should block drain");
713    }
714
715    #[test]
716    fn bootstrap_state_explicit_drained_overrides() {
717        let mut state = BootstrapState::new();
718        state.pending_peer_requests = 5;
719        state.pending_keys.insert([99u8; 32]);
720        state.drained = true;
721        assert!(
722            state.is_drained(),
723            "explicit drained flag should override pending counts"
724        );
725    }
726
727    #[test]
728    fn bootstrap_state_requires_explicit_drain() {
729        let mut state = BootstrapState::new();
730        state.pending_peer_requests = 2;
731        state.pending_keys.insert([1u8; 32]);
732
733        // Simulate completing work — but without explicit drain flag.
734        state.pending_peer_requests = 0;
735        state.pending_keys.clear();
736
737        assert!(
738            !state.is_drained(),
739            "clearing counters alone must not drain — requires check_bootstrap_drained"
740        );
741
742        // Explicit drain (set by check_bootstrap_drained or mark_bootstrap_drained).
743        state.drained = true;
744        assert!(state.is_drained(), "explicit flag should drain");
745    }
746
747    #[test]
748    fn bootstrap_state_default_matches_new() {
749        let from_new = BootstrapState::new();
750        let from_default = BootstrapState::default();
751
752        assert_eq!(from_new.drained, from_default.drained);
753        assert_eq!(
754            from_new.pending_peer_requests,
755            from_default.pending_peer_requests
756        );
757        assert_eq!(from_new.pending_keys, from_default.pending_keys);
758    }
759
760    // -- Scenario tests -------------------------------------------------------
761
762    /// #13: Bootstrap not drained while `pending_keys` overlap with the
763    /// pipeline. Keys must be removed from `pending_keys` for drain to occur.
764    #[test]
765    fn bootstrap_drain_requires_empty_pending_keys() {
766        let key_a: XorName = [0xA0; 32];
767        let key_b: XorName = [0xB0; 32];
768        let key_c: XorName = [0xC0; 32];
769
770        let mut state = BootstrapState::new();
771        state.pending_peer_requests = 0; // requests already done
772        state.pending_keys = std::iter::once(key_a)
773            .chain(std::iter::once(key_b))
774            .chain(std::iter::once(key_c))
775            .collect();
776
777        assert!(
778            !state.is_drained(),
779            "should NOT be drained while pending_keys still has entries"
780        );
781
782        // Simulate pipeline processing — remove one key at a time.
783        state.pending_keys.remove(&key_a);
784        assert!(!state.is_drained(), "still not drained with 2 pending keys");
785
786        state.pending_keys.remove(&key_b);
787        assert!(!state.is_drained(), "still not drained with 1 pending key");
788
789        state.pending_keys.remove(&key_c);
790        assert!(
791            !state.is_drained(),
792            "removing all keys is necessary but not sufficient — needs explicit drain"
793        );
794
795        // Simulate check_bootstrap_drained setting the flag.
796        state.drained = true;
797        assert!(state.is_drained(), "explicit drain flag should finalize");
798    }
799
800    /// Verify that the FSM terminal states are distinguishable and document
801    /// which variants are logically terminal (no outgoing transitions).
802    #[test]
803    fn verification_state_terminal_variants() {
804        let terminal_states = [
805            VerificationState::QuorumAbandoned,
806            VerificationState::FetchAbandoned,
807            VerificationState::Stored,
808            VerificationState::Idle,
809        ];
810
811        // All terminal states must be distinct from each other.
812        for (i, a) in terminal_states.iter().enumerate() {
813            for (j, b) in terminal_states.iter().enumerate() {
814                if i != j {
815                    assert_ne!(
816                        a, b,
817                        "terminal states at indices {i} and {j} must be distinct"
818                    );
819                }
820            }
821        }
822
823        // Terminal states must be distinct from all non-terminal states.
824        let non_terminal_states = [
825            VerificationState::OfferReceived,
826            VerificationState::PendingVerify,
827            VerificationState::QuorumVerified,
828            VerificationState::PaidListVerified,
829            VerificationState::QueuedForFetch,
830            VerificationState::Fetching,
831            VerificationState::FetchRetryable,
832            VerificationState::QuorumFailed,
833            VerificationState::QuorumInconclusive,
834        ];
835
836        for terminal in &terminal_states {
837            for non_terminal in &non_terminal_states {
838                assert_ne!(
839                    terminal, non_terminal,
840                    "terminal state {terminal:?} must not equal non-terminal state {non_terminal:?}"
841                );
842            }
843        }
844    }
845
846    /// `has_repair_opportunity` requires BOTH a previous sync AND at least
847    /// one subsequent cycle.
848    #[test]
849    fn repair_opportunity_requires_both_sync_and_cycle() {
850        // last_sync = Some, cycles_since_sync = 0 → false (synced but no cycle yet)
851        let synced_no_cycle = PeerSyncRecord {
852            last_sync: Some(
853                Instant::now()
854                    .checked_sub(std::time::Duration::from_secs(2))
855                    .unwrap_or_else(Instant::now),
856            ),
857            cycles_since_sync: 0,
858        };
859        assert!(
860            !synced_no_cycle.has_repair_opportunity(),
861            "synced with zero subsequent cycles should NOT have repair opportunity"
862        );
863
864        // last_sync = None, cycles_since_sync = 5 → false (never synced)
865        let never_synced = PeerSyncRecord {
866            last_sync: None,
867            cycles_since_sync: 5,
868        };
869        assert!(
870            !never_synced.has_repair_opportunity(),
871            "never-synced peer should NOT have repair opportunity regardless of cycles"
872        );
873
874        // last_sync = Some, cycles_since_sync = 1 → true
875        let ready = PeerSyncRecord {
876            last_sync: Some(
877                Instant::now()
878                    .checked_sub(std::time::Duration::from_secs(5))
879                    .unwrap_or_else(Instant::now),
880            ),
881            cycles_since_sync: 1,
882        };
883        assert!(
884            ready.has_repair_opportunity(),
885            "synced peer with >= 1 cycle SHOULD have repair opportunity"
886        );
887    }
888}