Skip to main content

fsqlite_core/
ecs_replication.rs

1//! ECS-native replication architecture (§3.4.7, bd-1hi.19).
2//!
3//! High-level replication framework: roles, modes, anti-entropy convergence,
4//! quorum durability, consistent-hash symbol routing, and authenticated symbols.
5
6use std::collections::{BTreeSet, HashMap, HashSet};
7
8use fsqlite_error::{FrankenError, Result};
9use tracing::{debug, error, info, warn};
10
11// ---------------------------------------------------------------------------
12// Constants
13// ---------------------------------------------------------------------------
14
15const BEAD_ID: &str = "bd-1hi.19";
16
17// ---------------------------------------------------------------------------
18// Replication roles and modes
19// ---------------------------------------------------------------------------
20
21/// Replication role for a node.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum ReplicationRole {
24    /// Publishes authoritative commit-marker stream. Accepts MVCC writes.
25    Leader,
26    /// Replicates objects + markers, serves reads.
27    Follower,
28}
29
30/// Replication mode (§3.4.7 spec).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32pub enum ReplicationMode {
33    /// One leader publishes markers. V1 default.
34    #[default]
35    LeaderCommitClock,
36    /// Multiple nodes publish capsules. Experimental, not V1 default.
37    MultiWriter,
38}
39
40// ---------------------------------------------------------------------------
41// Replicated object types
42// ---------------------------------------------------------------------------
43
44/// Object ID — 16-byte content-addressed identifier.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
46pub struct ObjectId([u8; 16]);
47
48impl ObjectId {
49    #[must_use]
50    pub const fn from_bytes(b: [u8; 16]) -> Self {
51        Self(b)
52    }
53
54    #[must_use]
55    pub const fn as_bytes(&self) -> &[u8; 16] {
56        &self.0
57    }
58}
59
60/// Categories of ECS objects that are replicated.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum ReplicatedObjectKind {
63    CommitCapsule,
64    CommitMarker,
65    IndexSegment,
66    ReadWitness,
67    WriteWitness,
68    WitnessDelta,
69    WitnessIndexSegment,
70    DependencyEdge,
71    CommitProof,
72    AbortWitness,
73    MergeWitness,
74    CheckpointChunk,
75    SnapshotManifest,
76    DecodeProof,
77}
78
79/// A commit marker record — the commit clock.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CommitMarker {
82    pub commit_seq: u64,
83    pub capsule_id: ObjectId,
84    pub timestamp_ns: u64,
85}
86
87/// Idempotency key for commit-level replication deduplication.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct IdempotencyKey([u8; 16]);
90
91impl IdempotencyKey {
92    /// Derive an idempotency key from commit identity fields.
93    #[must_use]
94    pub fn from_marker(marker: &CommitMarker) -> Self {
95        let mut hasher = blake3::Hasher::new();
96        hasher.update(b"fsqlite:repl:idempotency:v1");
97        hasher.update(&marker.commit_seq.to_le_bytes());
98        hasher.update(marker.capsule_id.as_bytes());
99        let hash = hasher.finalize();
100        let mut out = [0_u8; 16];
101        out.copy_from_slice(&hash.as_bytes()[..16]);
102        Self(out)
103    }
104}
105
106/// Tracks replicated commits and suppresses duplicates by idempotency key.
107#[derive(Debug, Default)]
108pub struct CommitDeduplicator {
109    seen: HashSet<IdempotencyKey>,
110}
111
112impl CommitDeduplicator {
113    /// Returns true if the marker is new and should be replicated/applied.
114    pub fn should_accept(&mut self, marker: &CommitMarker) -> bool {
115        self.seen.insert(IdempotencyKey::from_marker(marker))
116    }
117
118    /// Number of unique commits seen.
119    #[must_use]
120    pub fn seen_count(&self) -> usize {
121        self.seen.len()
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Anti-entropy protocol
127// ---------------------------------------------------------------------------
128
129/// Tip information exchanged between replicas.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ReplicaTip {
132    /// Latest root manifest object ID.
133    pub root_manifest_id: ObjectId,
134    /// Latest marker stream position (commit sequence number).
135    pub marker_position: u64,
136    /// Optional index segment tips.
137    pub index_segment_tips: Vec<ObjectId>,
138}
139
140/// Result of computing missing objects between two replicas.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct MissingObjects {
143    /// Objects present in remote but not local.
144    pub needed: BTreeSet<ObjectId>,
145    /// Objects present locally but not remote.
146    pub to_offer: BTreeSet<ObjectId>,
147}
148
149/// Anti-entropy convergence protocol state.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum AntiEntropyPhase {
152    /// Step 1: Exchange tips.
153    ExchangeTips,
154    /// Step 2: Compute missing objects.
155    ComputeMissing,
156    /// Step 3: Request symbols for missing objects.
157    RequestSymbols,
158    /// Step 4: Stream symbols until decode.
159    StreamUntilDecode,
160    /// Step 5: Persist and update.
161    PersistAndUpdate,
162    /// Converged.
163    Complete,
164}
165
166/// Anti-entropy session between two replicas.
167#[derive(Debug)]
168pub struct AntiEntropySession {
169    phase: AntiEntropyPhase,
170    local_tip: Option<ReplicaTip>,
171    remote_tip: Option<ReplicaTip>,
172    missing: Option<MissingObjects>,
173    decoded_objects: HashSet<ObjectId>,
174}
175
176impl AntiEntropySession {
177    /// Create a new anti-entropy session.
178    #[must_use]
179    pub fn new() -> Self {
180        debug!(bead_id = BEAD_ID, "starting anti-entropy session");
181        Self {
182            phase: AntiEntropyPhase::ExchangeTips,
183            local_tip: None,
184            remote_tip: None,
185            missing: None,
186            decoded_objects: HashSet::new(),
187        }
188    }
189
190    /// Current phase.
191    #[must_use]
192    pub const fn phase(&self) -> AntiEntropyPhase {
193        self.phase
194    }
195
196    /// Step 1: Set local and remote tips.
197    pub fn exchange_tips(&mut self, local: ReplicaTip, remote: ReplicaTip) -> Result<()> {
198        if self.phase != AntiEntropyPhase::ExchangeTips {
199            return Err(FrankenError::Internal(format!(
200                "anti-entropy: expected ExchangeTips, got {:?}",
201                self.phase
202            )));
203        }
204        debug!(
205            bead_id = BEAD_ID,
206            local_pos = local.marker_position,
207            remote_pos = remote.marker_position,
208            "exchanged tips"
209        );
210        self.local_tip = Some(local);
211        self.remote_tip = Some(remote);
212        self.phase = AntiEntropyPhase::ComputeMissing;
213        Ok(())
214    }
215
216    /// Step 2: Compute missing objects from local and remote object sets.
217    pub fn compute_missing(
218        &mut self,
219        local_objects: &BTreeSet<ObjectId>,
220        remote_objects: &BTreeSet<ObjectId>,
221    ) -> Result<&MissingObjects> {
222        if self.phase != AntiEntropyPhase::ComputeMissing {
223            return Err(FrankenError::Internal(format!(
224                "anti-entropy: expected ComputeMissing, got {:?}",
225                self.phase
226            )));
227        }
228
229        let needed: BTreeSet<ObjectId> =
230            remote_objects.difference(local_objects).copied().collect();
231        let to_offer: BTreeSet<ObjectId> =
232            local_objects.difference(remote_objects).copied().collect();
233
234        debug!(
235            bead_id = BEAD_ID,
236            needed_count = needed.len(),
237            to_offer_count = to_offer.len(),
238            "computed missing objects"
239        );
240
241        self.missing = Some(MissingObjects { needed, to_offer });
242        self.phase = AntiEntropyPhase::RequestSymbols;
243        Ok(self.missing.as_ref().expect("just set"))
244    }
245
246    /// Step 3: Return the set of object IDs we need symbols for.
247    #[must_use]
248    pub fn objects_to_request(&self) -> Option<&BTreeSet<ObjectId>> {
249        self.missing.as_ref().map(|m| &m.needed)
250    }
251
252    /// Step 4: Record that we received enough symbols and decoded an object.
253    pub fn record_decoded(&mut self, object_id: ObjectId) -> Result<()> {
254        if self.phase != AntiEntropyPhase::RequestSymbols
255            && self.phase != AntiEntropyPhase::StreamUntilDecode
256        {
257            return Err(FrankenError::Internal(format!(
258                "anti-entropy: expected RequestSymbols/StreamUntilDecode, got {:?}",
259                self.phase
260            )));
261        }
262        self.phase = AntiEntropyPhase::StreamUntilDecode;
263        self.decoded_objects.insert(object_id);
264
265        // Check if all needed objects are decoded.
266        if let Some(missing) = &self.missing
267            && missing
268                .needed
269                .iter()
270                .all(|id| self.decoded_objects.contains(id))
271        {
272            debug!(
273                bead_id = BEAD_ID,
274                decoded_count = self.decoded_objects.len(),
275                "all missing objects decoded"
276            );
277            self.phase = AntiEntropyPhase::PersistAndUpdate;
278        }
279        Ok(())
280    }
281
282    /// Step 5: Finalize — persist and update local state.
283    pub fn finalize(&mut self) -> Result<()> {
284        if self.phase != AntiEntropyPhase::PersistAndUpdate {
285            return Err(FrankenError::Internal(format!(
286                "anti-entropy: expected PersistAndUpdate, got {:?}",
287                self.phase
288            )));
289        }
290        info!(
291            bead_id = BEAD_ID,
292            decoded_count = self.decoded_objects.len(),
293            "anti-entropy session complete — persisted"
294        );
295        self.phase = AntiEntropyPhase::Complete;
296        Ok(())
297    }
298
299    /// Check if the session has converged.
300    #[must_use]
301    pub const fn is_converged(&self) -> bool {
302        matches!(self.phase, AntiEntropyPhase::Complete)
303    }
304}
305
306impl Default for AntiEntropySession {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312// ---------------------------------------------------------------------------
313// Quorum durability
314// ---------------------------------------------------------------------------
315
316/// Quorum durability policy.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct QuorumPolicy {
319    /// Minimum stores that must accept symbols before commit is durable.
320    pub required: u32,
321    /// Total number of stores in the quorum set.
322    pub total: u32,
323}
324
325impl QuorumPolicy {
326    /// Create a local-only policy: quorum(1, 1).
327    #[must_use]
328    pub const fn local_only() -> Self {
329        Self {
330            required: 1,
331            total: 1,
332        }
333    }
334
335    /// Create a 2-of-3 policy.
336    #[must_use]
337    pub const fn two_of_three() -> Self {
338        Self {
339            required: 2,
340            total: 3,
341        }
342    }
343
344    /// Create a custom quorum policy.
345    pub fn new(required: u32, total: u32) -> Result<Self> {
346        if required == 0 || required > total {
347            return Err(FrankenError::Internal(format!(
348                "invalid quorum: required={required}, total={total}"
349            )));
350        }
351        Ok(Self { required, total })
352    }
353}
354
355/// Tracks store acknowledgements for quorum satisfaction.
356#[derive(Debug)]
357pub struct QuorumTracker {
358    policy: QuorumPolicy,
359    accepted: HashSet<u32>,
360}
361
362impl QuorumTracker {
363    /// Create a new tracker for the given policy.
364    #[must_use]
365    pub fn new(policy: QuorumPolicy) -> Self {
366        Self {
367            policy,
368            accepted: HashSet::new(),
369        }
370    }
371
372    /// Record that store `store_id` has accepted sufficient symbols.
373    pub fn record_acceptance(&mut self, store_id: u32) {
374        self.accepted.insert(store_id);
375        debug!(
376            bead_id = BEAD_ID,
377            store_id,
378            accepted = self.accepted.len(),
379            required = self.policy.required,
380            "store accepted symbols"
381        );
382    }
383
384    /// Check if quorum is satisfied.
385    #[must_use]
386    #[allow(clippy::cast_possible_truncation)]
387    pub fn is_satisfied(&self) -> bool {
388        self.accepted.len() as u32 >= self.policy.required
389    }
390
391    /// Number of stores that have accepted.
392    #[must_use]
393    pub fn accepted_count(&self) -> usize {
394        self.accepted.len()
395    }
396
397    /// Policy reference.
398    #[must_use]
399    pub const fn policy(&self) -> &QuorumPolicy {
400        &self.policy
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Consistent-hash symbol routing
406// ---------------------------------------------------------------------------
407
408/// Consistent hash ring for symbol routing.
409#[derive(Debug, Clone)]
410pub struct ConsistentHashRing {
411    /// (hash_value, node_id) sorted by hash_value.
412    ring: Vec<(u64, u32)>,
413    /// Number of virtual nodes per physical node.
414    vnodes: u32,
415}
416
417impl ConsistentHashRing {
418    /// Create a ring with the given node IDs and virtual node count.
419    #[must_use]
420    pub fn new(node_ids: &[u32], vnodes: u32) -> Self {
421        let mut ring = Vec::with_capacity(node_ids.len() * vnodes as usize);
422        for &nid in node_ids {
423            for v in 0..vnodes {
424                let hash = Self::hash_vnode(nid, v);
425                ring.push((hash, nid));
426            }
427        }
428        ring.sort_unstable_by_key(|&(h, _)| h);
429        Self { ring, vnodes }
430    }
431
432    /// Route a symbol (identified by `object_id` + `esi`) to a node.
433    #[must_use]
434    pub fn route(&self, object_id: &ObjectId, esi: u32) -> Option<u32> {
435        if self.ring.is_empty() {
436            return None;
437        }
438        let key = Self::hash_symbol(object_id, esi);
439        // Binary search for the first ring entry >= key.
440        let idx = self.ring.partition_point(|&(h, _)| h < key);
441        let idx = if idx >= self.ring.len() { 0 } else { idx };
442        Some(self.ring[idx].1)
443    }
444
445    /// Add a node to the ring. Returns the set of symbols that need to be re-routed.
446    #[must_use]
447    pub fn add_node(&mut self, node_id: u32) -> Self {
448        let mut node_ids: BTreeSet<u32> = self.ring.iter().map(|&(_, n)| n).collect();
449        node_ids.insert(node_id);
450        let ids: Vec<u32> = node_ids.into_iter().collect();
451        Self::new(&ids, self.vnodes)
452    }
453
454    /// Number of distinct physical nodes in the ring.
455    #[must_use]
456    pub fn node_count(&self) -> usize {
457        let nodes: HashSet<u32> = self.ring.iter().map(|&(_, n)| n).collect();
458        nodes.len()
459    }
460
461    fn hash_vnode(node_id: u32, vnode: u32) -> u64 {
462        let mut buf = [0u8; 8];
463        buf[..4].copy_from_slice(&node_id.to_le_bytes());
464        buf[4..8].copy_from_slice(&vnode.to_le_bytes());
465        xxhash_rust::xxh3::xxh3_64(&buf)
466    }
467
468    fn hash_symbol(object_id: &ObjectId, esi: u32) -> u64 {
469        let mut buf = [0u8; 20];
470        buf[..16].copy_from_slice(object_id.as_bytes());
471        buf[16..20].copy_from_slice(&esi.to_le_bytes());
472        xxhash_rust::xxh3::xxh3_64(&buf)
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Authenticated symbols
478// ---------------------------------------------------------------------------
479
480/// An authenticated symbol with an auth tag.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct AuthenticatedSymbol {
483    pub object_id: ObjectId,
484    pub esi: u32,
485    pub data: Vec<u8>,
486    /// Auth tag for integrity verification.
487    pub auth_tag: [u8; 16],
488}
489
490impl AuthenticatedSymbol {
491    /// Compute expected auth tag for the given data.
492    #[must_use]
493    pub fn compute_auth_tag(object_id: &ObjectId, esi: u32, data: &[u8]) -> [u8; 16] {
494        let mut hasher = blake3::Hasher::new();
495        hasher.update(b"fsqlite:repl:auth:v1");
496        hasher.update(object_id.as_bytes());
497        hasher.update(&esi.to_le_bytes());
498        hasher.update(data);
499        let hash = hasher.finalize();
500        let mut tag = [0u8; 16];
501        tag.copy_from_slice(&hash.as_bytes()[..16]);
502        tag
503    }
504
505    /// Verify that this symbol's auth tag is valid.
506    #[must_use]
507    pub fn verify(&self) -> bool {
508        let expected = Self::compute_auth_tag(&self.object_id, self.esi, &self.data);
509        self.auth_tag == expected
510    }
511
512    /// Create a new authenticated symbol with a correct auth tag.
513    #[must_use]
514    pub fn new(object_id: ObjectId, esi: u32, data: Vec<u8>) -> Self {
515        let auth_tag = Self::compute_auth_tag(&object_id, esi, &data);
516        Self {
517            object_id,
518            esi,
519            data,
520            auth_tag,
521        }
522    }
523}
524
525// ---------------------------------------------------------------------------
526// Replication configuration
527// ---------------------------------------------------------------------------
528
529/// Configuration for the replication subsystem.
530#[derive(Debug, Clone)]
531pub struct ReplicationConfig {
532    pub role: ReplicationRole,
533    pub mode: ReplicationMode,
534    pub quorum: QuorumPolicy,
535    pub security_enabled: bool,
536    pub multi_writer_explicit: bool,
537}
538
539impl Default for ReplicationConfig {
540    fn default() -> Self {
541        Self {
542            role: ReplicationRole::Leader,
543            mode: ReplicationMode::LeaderCommitClock,
544            quorum: QuorumPolicy::local_only(),
545            security_enabled: false,
546            multi_writer_explicit: false,
547        }
548    }
549}
550
551/// Validate replication config. Multi-writer requires explicit opt-in.
552pub fn validate_config(config: &ReplicationConfig) -> Result<()> {
553    if config.mode == ReplicationMode::MultiWriter && !config.multi_writer_explicit {
554        error!(
555            bead_id = BEAD_ID,
556            "multi-writer mode requires explicit configuration"
557        );
558        return Err(FrankenError::Internal(
559            "multi-writer replication mode requires explicit opt-in via multi_writer_explicit=true"
560                .into(),
561        ));
562    }
563    info!(
564        bead_id = BEAD_ID,
565        role = ?config.role,
566        mode = ?config.mode,
567        quorum_required = config.quorum.required,
568        quorum_total = config.quorum.total,
569        security = config.security_enabled,
570        "replication config validated"
571    );
572    Ok(())
573}
574
575// ---------------------------------------------------------------------------
576// Commit publication gate
577// ---------------------------------------------------------------------------
578
579/// Manages the commit-publication gate: markers are not published until
580/// the durability quorum is satisfied.
581#[derive(Debug)]
582pub struct CommitPublicationGate {
583    tracker: QuorumTracker,
584    marker: Option<CommitMarker>,
585    published: bool,
586}
587
588impl CommitPublicationGate {
589    /// Create a gate for the given marker and quorum policy.
590    #[must_use]
591    pub fn new(marker: CommitMarker, policy: QuorumPolicy) -> Self {
592        Self {
593            tracker: QuorumTracker::new(policy),
594            marker: Some(marker),
595            published: false,
596        }
597    }
598
599    /// Record that a store accepted the commit's symbols.
600    pub fn record_store_acceptance(&mut self, store_id: u32) {
601        self.tracker.record_acceptance(store_id);
602    }
603
604    /// Try to publish the marker. Returns the marker if quorum is met,
605    /// None if not yet satisfied.
606    pub fn try_publish(&mut self) -> Option<&CommitMarker> {
607        if self.published {
608            return self.marker.as_ref();
609        }
610        if self.tracker.is_satisfied() {
611            self.published = true;
612            info!(
613                bead_id = BEAD_ID,
614                accepted = self.tracker.accepted_count(),
615                required = self.tracker.policy().required,
616                "quorum satisfied — marker published"
617            );
618            self.marker.as_ref()
619        } else {
620            warn!(
621                bead_id = BEAD_ID,
622                accepted = self.tracker.accepted_count(),
623                required = self.tracker.policy().required,
624                "quorum not yet satisfied — marker withheld"
625            );
626            None
627        }
628    }
629
630    /// Is the marker published?
631    #[must_use]
632    pub const fn is_published(&self) -> bool {
633        self.published
634    }
635}
636
637// ---------------------------------------------------------------------------
638// Symbol filter for security
639// ---------------------------------------------------------------------------
640
641/// Filter authenticated symbols, rejecting invalid ones.
642pub fn filter_authenticated_symbols(
643    symbols: &[AuthenticatedSymbol],
644) -> (Vec<&AuthenticatedSymbol>, Vec<&AuthenticatedSymbol>) {
645    let mut accepted = Vec::new();
646    let mut rejected = Vec::new();
647
648    for sym in symbols {
649        if sym.verify() {
650            accepted.push(sym);
651        } else {
652            debug!(
653                bead_id = BEAD_ID,
654                esi = sym.esi,
655                "rejected unauthenticated symbol"
656            );
657            rejected.push(sym);
658        }
659    }
660
661    if !rejected.is_empty() {
662        warn!(
663            bead_id = BEAD_ID,
664            rejected_count = rejected.len(),
665            accepted_count = accepted.len(),
666            "filtered out unauthenticated symbols"
667        );
668    }
669
670    (accepted, rejected)
671}
672
673// ---------------------------------------------------------------------------
674// Sheaf consistency check (simplified)
675// ---------------------------------------------------------------------------
676
677/// A trace event for consistency checking.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct TraceEvent {
680    pub node_id: u32,
681    pub commit_seq: u64,
682    pub object_id: ObjectId,
683    pub event_type: TraceEventType,
684}
685
686/// Type of trace event.
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
688pub enum TraceEventType {
689    Published,
690    Received,
691    Applied,
692}
693
694/// Result of a sheaf consistency check.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub struct SheafCheckResult {
697    pub is_consistent: bool,
698    pub anomalies: Vec<SheafAnomaly>,
699}
700
701/// A consistency anomaly detected by sheaf check.
702#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct SheafAnomaly {
704    pub description: String,
705    pub commit_seq: u64,
706    pub involved_nodes: Vec<u32>,
707}
708
709/// Run a sheaf consistency check on a set of trace events.
710///
711/// Detects phantom commits: commits seen by no single node end-to-end
712/// (Published + Applied on the same node).
713#[must_use]
714pub fn sheaf_consistency_check(events: &[TraceEvent]) -> SheafCheckResult {
715    // Group events by (commit_seq, node_id).
716    let mut node_events: HashMap<(u64, u32), HashSet<TraceEventType>> = HashMap::new();
717    for ev in events {
718        node_events
719            .entry((ev.commit_seq, ev.node_id))
720            .or_default()
721            .insert(ev.event_type);
722    }
723
724    // Find all commit sequences.
725    let commit_seqs: BTreeSet<u64> = events.iter().map(|e| e.commit_seq).collect();
726    let all_nodes: BTreeSet<u32> = events.iter().map(|e| e.node_id).collect();
727
728    let mut anomalies = Vec::new();
729
730    for &seq in &commit_seqs {
731        // Check if any single node has both Published and Applied.
732        let has_end_to_end = all_nodes.iter().any(|&nid| {
733            let key = (seq, nid);
734            if let Some(types) = node_events.get(&key) {
735                types.contains(&TraceEventType::Published)
736                    && types.contains(&TraceEventType::Applied)
737            } else {
738                false
739            }
740        });
741
742        if !has_end_to_end {
743            // Phantom commit: no single node witnessed end-to-end.
744            let involved: Vec<u32> = all_nodes
745                .iter()
746                .filter(|&&nid| node_events.contains_key(&(seq, nid)))
747                .copied()
748                .collect();
749
750            if !involved.is_empty() {
751                anomalies.push(SheafAnomaly {
752                    description: format!(
753                        "phantom commit at seq {seq}: no single node has both Published and Applied"
754                    ),
755                    commit_seq: seq,
756                    involved_nodes: involved,
757                });
758            }
759        }
760    }
761
762    let is_consistent = anomalies.is_empty();
763
764    if is_consistent {
765        debug!(
766            bead_id = BEAD_ID,
767            commit_count = commit_seqs.len(),
768            "sheaf consistency check passed"
769        );
770    } else {
771        warn!(
772            bead_id = BEAD_ID,
773            anomaly_count = anomalies.len(),
774            "sheaf consistency check found anomalies"
775        );
776    }
777
778    SheafCheckResult {
779        is_consistent,
780        anomalies,
781    }
782}
783
784// ---------------------------------------------------------------------------
785// TLA+ trace export (simplified)
786// ---------------------------------------------------------------------------
787
788/// Export trace events as TLA+ behavior specification.
789#[must_use]
790pub fn export_tla_trace(events: &[TraceEvent]) -> String {
791    use std::fmt::Write;
792    let mut out = String::new();
793    let _ = writeln!(out, "---- MODULE ReplicationTrace ----");
794    let _ = writeln!(out, "EXTENDS Integers, Sequences, FiniteSets");
795    let _ = writeln!(out);
796    let _ = writeln!(out, "VARIABLES committed, applied");
797    let _ = writeln!(out);
798    let _ = writeln!(out, "Init ==");
799    let _ = writeln!(out, "  /\\ committed = {{}}");
800    let _ = writeln!(out, "  /\\ applied = {{}}");
801    let _ = writeln!(out);
802
803    for (i, ev) in events.iter().enumerate() {
804        let _ = writeln!(
805            out,
806            "\\* Step {i}: node={}, seq={}",
807            ev.node_id, ev.commit_seq
808        );
809        match ev.event_type {
810            TraceEventType::Published => {
811                let _ = writeln!(
812                    out,
813                    "Step{i} == committed' = committed \\cup {{{}}}",
814                    ev.commit_seq
815                );
816            }
817            TraceEventType::Applied => {
818                let _ = writeln!(
819                    out,
820                    "Step{i} == applied' = applied \\cup {{{}}}",
821                    ev.commit_seq
822                );
823            }
824            TraceEventType::Received => {
825                let _ = writeln!(out, "\\* Received event (no state change in this model)");
826            }
827        }
828        let _ = writeln!(out);
829    }
830
831    let _ = writeln!(out, "====");
832    out
833}
834
835// ---------------------------------------------------------------------------
836// Tests
837// ---------------------------------------------------------------------------
838
839#[cfg(test)]
840#[allow(clippy::too_many_lines)]
841mod tests {
842    use super::*;
843
844    fn make_oid(seed: u8) -> ObjectId {
845        let mut b = [0u8; 16];
846        b[0] = seed;
847        ObjectId::from_bytes(b)
848    }
849
850    // -- Compliance gates --
851
852    #[test]
853    fn test_bd_1hi_19_unit_compliance_gate() {
854        assert_eq!(BEAD_ID, "bd-1hi.19");
855        // Verify all required types exist.
856        let _ = ReplicationRole::Leader;
857        let _ = ReplicationRole::Follower;
858        let _ = ReplicationMode::LeaderCommitClock;
859        let _ = ReplicationMode::MultiWriter;
860        let _ = AntiEntropyPhase::ExchangeTips;
861        let _ = QuorumPolicy::local_only();
862    }
863
864    #[test]
865    fn prop_bd_1hi_19_structure_compliance() {
866        // Property: anti-entropy session progresses through all phases.
867        let mut session = AntiEntropySession::new();
868        assert_eq!(session.phase(), AntiEntropyPhase::ExchangeTips);
869
870        let local = ReplicaTip {
871            root_manifest_id: make_oid(1),
872            marker_position: 10,
873            index_segment_tips: vec![],
874        };
875        let remote = ReplicaTip {
876            root_manifest_id: make_oid(2),
877            marker_position: 12,
878            index_segment_tips: vec![],
879        };
880        session.exchange_tips(local, remote).unwrap();
881        assert_eq!(session.phase(), AntiEntropyPhase::ComputeMissing);
882    }
883
884    #[test]
885    fn test_e2e_bd_1hi_19_compliance() {
886        // E2E: full anti-entropy cycle with quorum gate.
887        let config = ReplicationConfig::default();
888        validate_config(&config).unwrap();
889
890        let mut session = AntiEntropySession::new();
891        let local = ReplicaTip {
892            root_manifest_id: make_oid(1),
893            marker_position: 5,
894            index_segment_tips: vec![],
895        };
896        let remote = ReplicaTip {
897            root_manifest_id: make_oid(2),
898            marker_position: 7,
899            index_segment_tips: vec![],
900        };
901        session.exchange_tips(local, remote).unwrap();
902
903        let local_objects: BTreeSet<ObjectId> = [make_oid(10), make_oid(20)].into();
904        let remote_objects: BTreeSet<ObjectId> = [make_oid(20), make_oid(30)].into();
905        let missing = session
906            .compute_missing(&local_objects, &remote_objects)
907            .unwrap();
908        assert!(missing.needed.contains(&make_oid(30)));
909
910        session.record_decoded(make_oid(30)).unwrap();
911        assert_eq!(session.phase(), AntiEntropyPhase::PersistAndUpdate);
912        session.finalize().unwrap();
913        assert!(session.is_converged());
914    }
915
916    // -- Leader-follower replication --
917
918    #[test]
919    fn test_leader_follower_replication() {
920        let config = ReplicationConfig {
921            role: ReplicationRole::Leader,
922            mode: ReplicationMode::LeaderCommitClock,
923            ..Default::default()
924        };
925        validate_config(&config).unwrap();
926
927        let follower_config = ReplicationConfig {
928            role: ReplicationRole::Follower,
929            mode: ReplicationMode::LeaderCommitClock,
930            ..Default::default()
931        };
932        validate_config(&follower_config).unwrap();
933    }
934
935    // -- Anti-entropy tests --
936
937    #[test]
938    fn test_anti_entropy_exchange_tips() {
939        let mut session = AntiEntropySession::new();
940        let local = ReplicaTip {
941            root_manifest_id: make_oid(1),
942            marker_position: 10,
943            index_segment_tips: vec![make_oid(100)],
944        };
945        let remote = ReplicaTip {
946            root_manifest_id: make_oid(2),
947            marker_position: 15,
948            index_segment_tips: vec![make_oid(200)],
949        };
950        session.exchange_tips(local, remote).unwrap();
951        assert_eq!(session.phase(), AntiEntropyPhase::ComputeMissing);
952    }
953
954    #[test]
955    fn test_anti_entropy_compute_missing() {
956        let mut session = AntiEntropySession::new();
957        session
958            .exchange_tips(
959                ReplicaTip {
960                    root_manifest_id: make_oid(1),
961                    marker_position: 0,
962                    index_segment_tips: vec![],
963                },
964                ReplicaTip {
965                    root_manifest_id: make_oid(2),
966                    marker_position: 0,
967                    index_segment_tips: vec![],
968                },
969            )
970            .unwrap();
971
972        let local: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
973        let remote: BTreeSet<ObjectId> = [make_oid(2), make_oid(3), make_oid(4)].into();
974
975        let missing = session.compute_missing(&local, &remote).unwrap();
976        assert_eq!(missing.needed, [make_oid(4)].into());
977        assert_eq!(missing.to_offer, [make_oid(1)].into());
978    }
979
980    #[test]
981    fn test_anti_entropy_stream_until_decode() {
982        let mut session = AntiEntropySession::new();
983        session
984            .exchange_tips(
985                ReplicaTip {
986                    root_manifest_id: make_oid(1),
987                    marker_position: 0,
988                    index_segment_tips: vec![],
989                },
990                ReplicaTip {
991                    root_manifest_id: make_oid(2),
992                    marker_position: 0,
993                    index_segment_tips: vec![],
994                },
995            )
996            .unwrap();
997
998        let local: BTreeSet<ObjectId> = [make_oid(1)].into();
999        let remote: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
1000        session.compute_missing(&local, &remote).unwrap();
1001
1002        // Decode objects one by one.
1003        session.record_decoded(make_oid(2)).unwrap();
1004        assert_eq!(session.phase(), AntiEntropyPhase::StreamUntilDecode);
1005        session.record_decoded(make_oid(3)).unwrap();
1006        assert_eq!(session.phase(), AntiEntropyPhase::PersistAndUpdate);
1007    }
1008
1009    #[test]
1010    fn test_anti_entropy_convergence() {
1011        let mut session = AntiEntropySession::new();
1012        session
1013            .exchange_tips(
1014                ReplicaTip {
1015                    root_manifest_id: make_oid(1),
1016                    marker_position: 0,
1017                    index_segment_tips: vec![],
1018                },
1019                ReplicaTip {
1020                    root_manifest_id: make_oid(2),
1021                    marker_position: 0,
1022                    index_segment_tips: vec![],
1023                },
1024            )
1025            .unwrap();
1026
1027        let local: BTreeSet<ObjectId> = [make_oid(1), make_oid(2)].into();
1028        let remote: BTreeSet<ObjectId> = [make_oid(2), make_oid(3)].into();
1029        session.compute_missing(&local, &remote).unwrap();
1030        session.record_decoded(make_oid(3)).unwrap();
1031        session.finalize().unwrap();
1032        assert!(session.is_converged());
1033    }
1034
1035    // -- Quorum tests --
1036
1037    #[test]
1038    fn test_quorum_local_only() {
1039        let policy = QuorumPolicy::local_only();
1040        let mut tracker = QuorumTracker::new(policy);
1041        assert!(!tracker.is_satisfied());
1042        tracker.record_acceptance(0);
1043        assert!(tracker.is_satisfied());
1044    }
1045
1046    #[test]
1047    fn test_quorum_2_of_3() {
1048        let policy = QuorumPolicy::two_of_three();
1049        let mut tracker = QuorumTracker::new(policy);
1050        assert!(!tracker.is_satisfied());
1051        tracker.record_acceptance(0);
1052        assert!(!tracker.is_satisfied()); // 1 of 3.
1053        tracker.record_acceptance(1);
1054        assert!(tracker.is_satisfied()); // 2 of 3.
1055        tracker.record_acceptance(2);
1056        assert!(tracker.is_satisfied()); // 3 of 3 — still satisfied.
1057    }
1058
1059    #[test]
1060    fn test_quorum_blocks_marker_publication() {
1061        let marker = CommitMarker {
1062            commit_seq: 42,
1063            capsule_id: make_oid(10),
1064            timestamp_ns: 1_000_000,
1065        };
1066        let policy = QuorumPolicy::two_of_three();
1067        let mut gate = CommitPublicationGate::new(marker, policy);
1068
1069        // Marker not published before quorum.
1070        assert!(!gate.is_published());
1071        assert!(gate.try_publish().is_none());
1072
1073        gate.record_store_acceptance(0);
1074        assert!(gate.try_publish().is_none()); // 1 of 2 needed.
1075
1076        gate.record_store_acceptance(1);
1077        let published = gate.try_publish();
1078        assert!(published.is_some());
1079        assert_eq!(published.unwrap().commit_seq, 42);
1080        assert!(gate.is_published());
1081    }
1082
1083    // -- Symbol routing tests --
1084
1085    #[test]
1086    fn test_symbol_routing_consistent_hash() {
1087        let ring = ConsistentHashRing::new(&[1, 2, 3], 100);
1088        assert_eq!(ring.node_count(), 3);
1089
1090        let oid = make_oid(42);
1091        let node = ring.route(&oid, 0).unwrap();
1092        assert!([1, 2, 3].contains(&node));
1093
1094        // Deterministic: same input → same output.
1095        let node2 = ring.route(&oid, 0).unwrap();
1096        assert_eq!(node, node2);
1097    }
1098
1099    #[test]
1100    fn test_symbol_routing_add_node_minimal_reroute() {
1101        let mut ring3 = ConsistentHashRing::new(&[1, 2, 3], 100);
1102        let ring4 = ring3.add_node(4);
1103        assert_eq!(ring4.node_count(), 4);
1104
1105        // Most symbols should stay on same node. Count reroutes.
1106        let oid = make_oid(1);
1107        let mut rerouted = 0_u32;
1108        for esi in 0..1000 {
1109            let n3 = ring3.route(&oid, esi).unwrap();
1110            let n4 = ring4.route(&oid, esi).unwrap();
1111            if n3 != n4 {
1112                rerouted += 1;
1113            }
1114        }
1115        // Adding 1 of 4 nodes should reroute roughly 25% (with consistent hashing).
1116        // Allow wide margin due to hash distribution.
1117        assert!(rerouted < 500, "too many reroutes: {rerouted}/1000");
1118    }
1119
1120    // -- Authenticated symbols tests --
1121
1122    #[test]
1123    fn test_authenticated_symbols_verified() {
1124        let sym = AuthenticatedSymbol::new(make_oid(1), 0, vec![1, 2, 3]);
1125        assert!(sym.verify());
1126
1127        // Tampered data.
1128        let mut bad = sym.clone();
1129        bad.data[0] = 99;
1130        assert!(!bad.verify());
1131
1132        // Tampered auth_tag.
1133        let mut bad2 = sym;
1134        bad2.auth_tag[0] ^= 0xFF;
1135        assert!(!bad2.verify());
1136    }
1137
1138    #[test]
1139    fn test_unauthenticated_fallback() {
1140        let good1 = AuthenticatedSymbol::new(make_oid(1), 0, vec![10, 20]);
1141        let good2 = AuthenticatedSymbol::new(make_oid(1), 1, vec![30, 40]);
1142        let mut bad = AuthenticatedSymbol::new(make_oid(1), 2, vec![50, 60]);
1143        bad.auth_tag[0] ^= 0xFF; // Corrupt.
1144
1145        let all = [good1, good2, bad];
1146        let (accepted, rejected) = filter_authenticated_symbols(&all);
1147        assert_eq!(accepted.len(), 2);
1148        assert_eq!(rejected.len(), 1);
1149        assert_eq!(rejected[0].esi, 2);
1150    }
1151
1152    // -- Sheaf consistency check --
1153
1154    #[test]
1155    fn test_sheaf_consistency_check_clean() {
1156        let events = vec![
1157            TraceEvent {
1158                node_id: 1,
1159                commit_seq: 1,
1160                object_id: make_oid(10),
1161                event_type: TraceEventType::Published,
1162            },
1163            TraceEvent {
1164                node_id: 1,
1165                commit_seq: 1,
1166                object_id: make_oid(10),
1167                event_type: TraceEventType::Applied,
1168            },
1169        ];
1170        let result = sheaf_consistency_check(&events);
1171        assert!(result.is_consistent);
1172        assert!(result.anomalies.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_sheaf_consistency_check_phantom() {
1177        // Phantom commit: node 1 Published, node 2 Applied, no single node has both.
1178        let events = vec![
1179            TraceEvent {
1180                node_id: 1,
1181                commit_seq: 1,
1182                object_id: make_oid(10),
1183                event_type: TraceEventType::Published,
1184            },
1185            TraceEvent {
1186                node_id: 2,
1187                commit_seq: 1,
1188                object_id: make_oid(10),
1189                event_type: TraceEventType::Applied,
1190            },
1191        ];
1192        let result = sheaf_consistency_check(&events);
1193        assert!(!result.is_consistent);
1194        assert_eq!(result.anomalies.len(), 1);
1195        assert_eq!(result.anomalies[0].commit_seq, 1);
1196    }
1197
1198    // -- TLA+ export --
1199
1200    #[test]
1201    fn test_tla_export() {
1202        let events = vec![
1203            TraceEvent {
1204                node_id: 1,
1205                commit_seq: 1,
1206                object_id: make_oid(10),
1207                event_type: TraceEventType::Published,
1208            },
1209            TraceEvent {
1210                node_id: 2,
1211                commit_seq: 1,
1212                object_id: make_oid(10),
1213                event_type: TraceEventType::Applied,
1214            },
1215        ];
1216        let tla = export_tla_trace(&events);
1217        assert!(tla.contains("MODULE ReplicationTrace"));
1218        assert!(tla.contains("committed"));
1219        assert!(tla.contains("applied"));
1220        assert!(tla.contains("===="));
1221    }
1222
1223    // -- Multi-writer gated --
1224
1225    #[test]
1226    fn test_multiwriter_not_default() {
1227        let config = ReplicationConfig {
1228            mode: ReplicationMode::MultiWriter,
1229            multi_writer_explicit: false,
1230            ..Default::default()
1231        };
1232        let result = validate_config(&config);
1233        assert!(result.is_err());
1234    }
1235
1236    #[test]
1237    fn test_multiwriter_explicit_ok() {
1238        let config = ReplicationConfig {
1239            mode: ReplicationMode::MultiWriter,
1240            multi_writer_explicit: true,
1241            ..Default::default()
1242        };
1243        validate_config(&config).unwrap();
1244    }
1245
1246    // -- Property tests --
1247
1248    #[test]
1249    fn prop_anti_entropy_convergence() {
1250        // For various random-ish object sets, anti-entropy always converges.
1251        for seed in 0..20_u8 {
1252            let local: BTreeSet<ObjectId> = (0..seed).map(|i| make_oid(i * 2)).collect();
1253            let remote: BTreeSet<ObjectId> = (0..seed).map(|i| make_oid(i * 2 + 1)).collect();
1254
1255            let mut session = AntiEntropySession::new();
1256            session
1257                .exchange_tips(
1258                    ReplicaTip {
1259                        root_manifest_id: make_oid(100),
1260                        marker_position: 0,
1261                        index_segment_tips: vec![],
1262                    },
1263                    ReplicaTip {
1264                        root_manifest_id: make_oid(200),
1265                        marker_position: 0,
1266                        index_segment_tips: vec![],
1267                    },
1268                )
1269                .unwrap();
1270            let missing = session.compute_missing(&local, &remote).unwrap();
1271            for &oid in &missing.needed.clone() {
1272                session.record_decoded(oid).unwrap();
1273            }
1274            if session.phase() == AntiEntropyPhase::PersistAndUpdate {
1275                session.finalize().unwrap();
1276            }
1277            // Either converged (had missing objects) or still at RequestSymbols (nothing missing).
1278            assert!(
1279                session.is_converged() || session.phase() == AntiEntropyPhase::RequestSymbols,
1280                "failed to converge for seed={seed}"
1281            );
1282        }
1283    }
1284
1285    #[test]
1286    fn prop_quorum_safety() {
1287        // For various M, N, quorum only reports satisfied when >= M accepts.
1288        for m in 1..=5_u32 {
1289            for n in m..=5 {
1290                let policy = QuorumPolicy::new(m, n).unwrap();
1291                let mut tracker = QuorumTracker::new(policy);
1292                for i in 0..m - 1 {
1293                    tracker.record_acceptance(i);
1294                    assert!(
1295                        !tracker.is_satisfied(),
1296                        "should not be satisfied with {} of {} (need {})",
1297                        i + 1,
1298                        n,
1299                        m
1300                    );
1301                }
1302                tracker.record_acceptance(m - 1);
1303                assert!(
1304                    tracker.is_satisfied(),
1305                    "should be satisfied with {m} of {n}"
1306                );
1307            }
1308        }
1309    }
1310
1311    // -- ECS replication ordering --
1312
1313    #[test]
1314    fn test_ecs_replication_ordering() {
1315        // Commit markers applied in commit_seq order.
1316        let markers = [
1317            CommitMarker {
1318                commit_seq: 1,
1319                capsule_id: make_oid(1),
1320                timestamp_ns: 100,
1321            },
1322            CommitMarker {
1323                commit_seq: 2,
1324                capsule_id: make_oid(2),
1325                timestamp_ns: 200,
1326            },
1327            CommitMarker {
1328                commit_seq: 3,
1329                capsule_id: make_oid(3),
1330                timestamp_ns: 300,
1331            },
1332        ];
1333
1334        // Verify ordering invariant.
1335        for w in markers.windows(2) {
1336            assert!(w[0].commit_seq < w[1].commit_seq);
1337        }
1338    }
1339
1340    #[test]
1341    fn test_ecs_replication_commit_capsules() {
1342        // Commit capsules replicate as ECS objects and appear in missing-set diff.
1343        let local_capsules: BTreeSet<ObjectId> = [make_oid(1), make_oid(2)].into();
1344        let remote_capsules: BTreeSet<ObjectId> = [make_oid(1), make_oid(2), make_oid(3)].into();
1345
1346        let mut session = AntiEntropySession::new();
1347        session
1348            .exchange_tips(
1349                ReplicaTip {
1350                    root_manifest_id: make_oid(10),
1351                    marker_position: 1,
1352                    index_segment_tips: vec![],
1353                },
1354                ReplicaTip {
1355                    root_manifest_id: make_oid(11),
1356                    marker_position: 2,
1357                    index_segment_tips: vec![],
1358                },
1359            )
1360            .unwrap();
1361
1362        let missing = session
1363            .compute_missing(&local_capsules, &remote_capsules)
1364            .unwrap();
1365        assert_eq!(missing.needed, [make_oid(3)].into());
1366    }
1367
1368    #[test]
1369    fn test_ecs_replication_dedup() {
1370        // Duplicate commit markers are suppressed by idempotency key.
1371        let marker = CommitMarker {
1372            commit_seq: 77,
1373            capsule_id: make_oid(9),
1374            timestamp_ns: 1_234,
1375        };
1376        let mut dedup = CommitDeduplicator::default();
1377
1378        assert!(dedup.should_accept(&marker));
1379        assert!(!dedup.should_accept(&marker));
1380        assert_eq!(dedup.seen_count(), 1);
1381    }
1382
1383    // -- E2E tests --
1384
1385    #[test]
1386    fn test_e2e_3_node_replication() {
1387        // Simulate 1 leader + 2 followers. Leader commits, followers converge.
1388        let mut leader_objects: BTreeSet<ObjectId> = BTreeSet::new();
1389
1390        // Leader commits 10 transactions.
1391        for i in 0..10_u8 {
1392            leader_objects.insert(make_oid(i));
1393        }
1394
1395        // Follower 1 starts empty.
1396        let follower1_objects: BTreeSet<ObjectId> = BTreeSet::new();
1397
1398        // Anti-entropy: follower 1 syncs with leader.
1399        let mut session = AntiEntropySession::new();
1400        session
1401            .exchange_tips(
1402                ReplicaTip {
1403                    root_manifest_id: make_oid(0),
1404                    marker_position: 0,
1405                    index_segment_tips: vec![],
1406                },
1407                ReplicaTip {
1408                    root_manifest_id: make_oid(9),
1409                    marker_position: 10,
1410                    index_segment_tips: vec![],
1411                },
1412            )
1413            .unwrap();
1414        let missing = session
1415            .compute_missing(&follower1_objects, &leader_objects)
1416            .unwrap();
1417        assert_eq!(missing.needed.len(), 10);
1418
1419        for &oid in &missing.needed.clone() {
1420            session.record_decoded(oid).unwrap();
1421        }
1422        session.finalize().unwrap();
1423        assert!(session.is_converged());
1424    }
1425
1426    #[test]
1427    fn test_e2e_node_failure_recovery() {
1428        // 3 stores, quorum 2 of 3. Kill store B. Leader still commits. Restart B.
1429        let policy = QuorumPolicy::two_of_three();
1430        let marker = CommitMarker {
1431            commit_seq: 1,
1432            capsule_id: make_oid(1),
1433            timestamp_ns: 1000,
1434        };
1435        let mut gate = CommitPublicationGate::new(marker, policy);
1436
1437        // Store A accepts.
1438        gate.record_store_acceptance(0);
1439        // Store B down — no acceptance.
1440        // Store C accepts.
1441        gate.record_store_acceptance(2);
1442
1443        // Quorum satisfied (A + C = 2 of 3).
1444        assert!(gate.try_publish().is_some());
1445    }
1446
1447    #[test]
1448    fn test_e2e_lossy_replication_convergence() {
1449        // Deterministic 10% lossy delivery across anti-entropy rounds converges.
1450        fn delivered_with_loss(oid: &ObjectId, round: u32, loss_per_mille: u64) -> bool {
1451            let mut material = [0_u8; 20];
1452            material[..16].copy_from_slice(oid.as_bytes());
1453            material[16..].copy_from_slice(&round.to_le_bytes());
1454            xxhash_rust::xxh3::xxh3_64(&material) % 1000 >= loss_per_mille
1455        }
1456
1457        let leader_objects: BTreeSet<ObjectId> = (0_u8..100).map(make_oid).collect();
1458        let mut follower_objects: BTreeSet<ObjectId> = BTreeSet::new();
1459
1460        for round in 0_u32..32 {
1461            if follower_objects == leader_objects {
1462                break;
1463            }
1464
1465            let mut session = AntiEntropySession::new();
1466            session
1467                .exchange_tips(
1468                    ReplicaTip {
1469                        root_manifest_id: make_oid(1),
1470                        marker_position: follower_objects.len() as u64,
1471                        index_segment_tips: vec![],
1472                    },
1473                    ReplicaTip {
1474                        root_manifest_id: make_oid(2),
1475                        marker_position: leader_objects.len() as u64,
1476                        index_segment_tips: vec![],
1477                    },
1478                )
1479                .unwrap();
1480
1481            let missing = session
1482                .compute_missing(&follower_objects, &leader_objects)
1483                .unwrap()
1484                .needed
1485                .clone();
1486
1487            for oid in missing {
1488                if delivered_with_loss(&oid, round, 100) {
1489                    session.record_decoded(oid).unwrap();
1490                    follower_objects.insert(oid);
1491                }
1492            }
1493
1494            if session.phase() == AntiEntropyPhase::PersistAndUpdate {
1495                session.finalize().unwrap();
1496            }
1497        }
1498
1499        assert_eq!(follower_objects, leader_objects);
1500    }
1501}