Skip to main content

de_mls/core/steward_list/
deterministic.rs

1//! Deterministic SHA256-sort steward list — reference
2//! [`super::StewardListPlugin`] implementation.
3
4use crate::core::{
5    DEFAULT_MAX_RETRIES, ElectionDecision, StewardList, StewardListConfig, StewardListEvent,
6    StewardListPlugin, error::CoreError,
7};
8
9#[derive(Debug)]
10pub struct DeterministicStewardList {
11    list: Option<StewardList>,
12    config: StewardListConfig,
13    conversation_id: Vec<u8>,
14    retry_round: u32,
15    max_retries: u32,
16}
17
18impl DeterministicStewardList {
19    /// Joiner-side: empty list. The coordinator fills it from the
20    /// `ConversationSync` it receives after the welcome.
21    pub fn empty(conversation_id: impl Into<Vec<u8>>, config: StewardListConfig) -> Self {
22        Self {
23            list: None,
24            config,
25            conversation_id: conversation_id.into(),
26            retry_round: 0,
27            max_retries: DEFAULT_MAX_RETRIES,
28        }
29    }
30
31    /// Creator-side: bootstrap with the creator as the sole steward at
32    /// epoch 0. No election, no retries.
33    pub fn with_creator(
34        conversation_id: impl Into<Vec<u8>>,
35        creator_identity: Vec<u8>,
36        config: StewardListConfig,
37    ) -> Result<Self, CoreError> {
38        let conversation_id = conversation_id.into();
39        let list = StewardList::generate(
40            0,
41            &conversation_id,
42            &[creator_identity],
43            1,
44            config.clone(),
45            0,
46        )?;
47        Ok(Self {
48            list: Some(list),
49            config,
50            conversation_id,
51            retry_round: 0,
52            max_retries: DEFAULT_MAX_RETRIES,
53        })
54    }
55}
56
57impl StewardListPlugin for DeterministicStewardList {
58    fn config(&self) -> &StewardListConfig {
59        &self.config
60    }
61
62    fn set_config(&mut self, config: StewardListConfig) {
63        self.config = config;
64    }
65
66    fn current_list(&self) -> Option<&StewardList> {
67        self.list.as_ref()
68    }
69
70    fn election_epoch(&self) -> Option<u64> {
71        self.list.as_ref().map(|l| l.election_epoch())
72    }
73
74    fn retry_round(&self) -> u32 {
75        self.retry_round
76    }
77
78    fn max_retries(&self) -> u32 {
79        self.max_retries
80    }
81
82    fn set_max_retries(&mut self, max: u32) {
83        self.max_retries = max;
84    }
85
86    fn is_steward(&self, identity: &[u8]) -> bool {
87        self.list.as_ref().is_some_and(|l| l.contains(identity))
88    }
89
90    fn is_exhausted(&self, epoch: u64) -> bool {
91        self.list.as_ref().is_some_and(|l| l.is_exhausted(epoch))
92    }
93
94    fn epoch_steward<F: Fn(&[u8]) -> bool>(&self, epoch: u64, eligible: F) -> Option<&[u8]> {
95        self.list
96            .as_ref()
97            .and_then(|l| l.live_steward_from(epoch, 0, eligible))
98    }
99
100    fn epoch_and_backup<F: Fn(&[u8]) -> bool>(
101        &self,
102        epoch: u64,
103        eligible: F,
104    ) -> (Option<&[u8]>, Option<&[u8]>) {
105        match self.list.as_ref() {
106            Some(l) => l.live_epoch_and_backup(epoch, eligible),
107            None => (None, None),
108        }
109    }
110
111    fn steward_members<F: Fn(&[u8]) -> bool>(&self, eligible: F) -> Vec<Vec<u8>> {
112        self.list
113            .as_ref()
114            .map(|l| {
115                l.members()
116                    .iter()
117                    .filter(|m| eligible(m))
118                    .cloned()
119                    .collect()
120            })
121            .unwrap_or_default()
122    }
123
124    fn election_proposer<F: Fn(&[u8]) -> bool>(&self, eligible: F) -> Option<&[u8]> {
125        // Election proposer = nominal index 0 = the steward whose
126        // rotation slot covers `election_epoch` itself.
127        self.list
128            .as_ref()
129            .and_then(|l| l.live_steward_from(l.election_epoch(), 0, eligible))
130    }
131
132    fn install_list(
133        &mut self,
134        epoch: u64,
135        candidate_pool: &[Vec<u8>],
136        sn: usize,
137        retry_round: u32,
138    ) -> Result<Vec<StewardListEvent>, CoreError> {
139        let list = StewardList::generate(
140            epoch,
141            &self.conversation_id,
142            candidate_pool,
143            sn,
144            self.config.clone(),
145            retry_round,
146        )?;
147        let len = list.len();
148        self.list = Some(list);
149        Ok(vec![StewardListEvent::ListInstalled {
150            epoch,
151            retry_round,
152            len,
153        }])
154    }
155
156    fn validate_proposed(
157        &self,
158        proposed: &[Vec<u8>],
159        epoch: u64,
160        candidate_pool: &[Vec<u8>],
161        retry_round: u32,
162    ) -> Result<bool, CoreError> {
163        StewardList::validate(
164            proposed,
165            epoch,
166            &self.conversation_id,
167            candidate_pool,
168            &self.config,
169            retry_round,
170        )
171    }
172
173    fn propose_election<F: Fn(&[u8]) -> bool>(
174        &self,
175        epoch: u64,
176        candidate_pool: &[Vec<u8>],
177        self_identity: &[u8],
178        eligible: F,
179        recovery: bool,
180    ) -> Result<ElectionDecision, CoreError> {
181        if !recovery && !self.is_exhausted(epoch) {
182            return Ok(ElectionDecision::Skip("steward list not exhausted"));
183        }
184        let is_authorized = self
185            .election_proposer(&eligible)
186            .is_some_and(|proposer| proposer == self_identity);
187        if !is_authorized {
188            return Ok(ElectionDecision::Skip("not the responsible proposer"));
189        }
190        if candidate_pool.is_empty() {
191            return Ok(ElectionDecision::Skip(
192                "no eligible candidates after filter",
193            ));
194        }
195
196        let retry_round = self.retry_round();
197        let sn = self.config.compute_list_size(candidate_pool.len());
198        let list = StewardList::generate(
199            epoch,
200            &self.conversation_id,
201            candidate_pool,
202            sn,
203            self.config.clone(),
204            retry_round,
205        )?;
206        Ok(ElectionDecision::Proposed {
207            proposed_stewards: list.members().to_vec(),
208            election_epoch: epoch,
209            retry_round,
210        })
211    }
212
213    fn maybe_auto_fill(
214        &mut self,
215        epoch: u64,
216        members: &[Vec<u8>],
217    ) -> Result<Vec<StewardListEvent>, CoreError> {
218        // RFC §Steward list creation: when total membership drops below
219        // `sn_min`, every member must be a steward. Re-derive over the
220        // full member set with `retry_round = 0` (auto-fill is not an
221        // election outcome).
222        if members.len() >= self.config.sn_min {
223            return Ok(Vec::new());
224        }
225        let sn = self.config.compute_list_size(members.len());
226        self.install_list(epoch, members, sn, 0)
227    }
228
229    fn bump_retry(&mut self) -> Vec<StewardListEvent> {
230        self.retry_round = self.retry_round.saturating_add(1);
231        if self.retry_round > self.max_retries {
232            vec![StewardListEvent::RetryExhausted {
233                round: self.retry_round,
234                max: self.max_retries,
235            }]
236        } else {
237            Vec::new()
238        }
239    }
240
241    fn reset_retry(&mut self) {
242        self.retry_round = 0;
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn member(id: u8) -> Vec<u8> {
251        vec![id; 20]
252    }
253
254    fn members(ids: &[u8]) -> Vec<Vec<u8>> {
255        ids.iter().map(|&id| member(id)).collect()
256    }
257
258    fn config() -> StewardListConfig {
259        StewardListConfig::new(1, 5).unwrap()
260    }
261
262    #[test]
263    fn empty_plugin_has_no_list() {
264        let p = DeterministicStewardList::empty(b"g".to_vec(), config());
265        assert!(p.current_list().is_none());
266        assert!(!p.is_steward(&member(1)));
267        assert!(!p.is_exhausted(0));
268        assert_eq!(p.epoch_steward(0, |_: &[u8]| true), None);
269        assert_eq!(p.election_epoch(), None);
270    }
271
272    #[test]
273    fn creator_bootstrap_makes_creator_a_steward() {
274        let creator = member(1);
275        let p = DeterministicStewardList::with_creator(b"g".to_vec(), creator.clone(), config())
276            .unwrap();
277        assert!(p.is_steward(&creator));
278        assert_eq!(p.election_epoch(), Some(0));
279        assert_eq!(p.current_list().unwrap().len(), 1);
280    }
281
282    #[test]
283    fn install_emits_list_installed_event() {
284        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
285        let mems = members(&[1, 2, 3]);
286        let events = p.install_list(0, &mems, 3, 0).unwrap();
287        assert_eq!(
288            events,
289            vec![StewardListEvent::ListInstalled {
290                epoch: 0,
291                retry_round: 0,
292                len: 3,
293            }]
294        );
295        assert_eq!(p.current_list().unwrap().len(), 3);
296        assert_eq!(p.election_epoch(), Some(0));
297    }
298
299    /// Filtering the nominal epoch steward out forces rotation to walk to
300    /// the next eligible candidate.
301    #[test]
302    fn epoch_steward_filters_by_eligibility() {
303        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
304        let mems = members(&[1, 2, 3]);
305        let _ = p.install_list(0, &mems, 3, 0).unwrap();
306
307        let nominal = p.epoch_steward(0, |_: &[u8]| true).unwrap().to_vec();
308        let next = p
309            .epoch_steward(0, |c: &[u8]| c != nominal.as_slice())
310            .unwrap();
311        assert_ne!(next, nominal.as_slice());
312        assert!(mems.iter().any(|m| m == next));
313    }
314
315    #[test]
316    fn epoch_and_backup_distinct_when_two_eligible() {
317        let mut p =
318            DeterministicStewardList::empty(b"g".to_vec(), StewardListConfig::new(3, 3).unwrap());
319        let mems = members(&[1, 2, 3]);
320        let _ = p.install_list(0, &mems, 3, 0).unwrap();
321
322        let (e, b) = p.epoch_and_backup(0, |_: &[u8]| true);
323        assert!(e.is_some() && b.is_some());
324        assert_ne!(e.unwrap(), b.unwrap());
325    }
326
327    /// Single eligible survivor → epoch resolves, backup stays None
328    /// (nothing to be distinct from).
329    #[test]
330    fn backup_is_none_when_only_one_eligible() {
331        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
332        let mems = members(&[1, 2, 3]);
333        let _ = p.install_list(0, &mems, 3, 0).unwrap();
334
335        let survivor = mems[0].clone();
336        let (e, b) = p.epoch_and_backup(0, |c: &[u8]| c == survivor.as_slice());
337        assert_eq!(e.unwrap(), survivor.as_slice());
338        assert!(b.is_none());
339    }
340
341    /// `bump_retry` past `max_retries` emits `RetryExhausted` exactly
342    /// once; default max is 1, so the second bump triggers it.
343    #[test]
344    fn bump_retry_emits_exhausted_after_max() {
345        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
346        assert!(p.bump_retry().is_empty());
347        assert_eq!(p.retry_round(), 1);
348
349        let events = p.bump_retry();
350        assert_eq!(
351            events,
352            vec![StewardListEvent::RetryExhausted { round: 2, max: 1 }]
353        );
354        assert_eq!(p.retry_round(), 2);
355    }
356
357    #[test]
358    fn reset_retry_clears_round() {
359        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
360        let _ = p.bump_retry();
361        let _ = p.bump_retry();
362        assert_eq!(p.retry_round(), 2);
363        p.reset_retry();
364        assert_eq!(p.retry_round(), 0);
365    }
366
367    #[test]
368    fn validate_proposed_against_self_derived_list() {
369        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
370        let mems = members(&[1, 2, 3]);
371        let _ = p.install_list(0, &mems, 3, 0).unwrap();
372        let proposed = p.current_list().unwrap().members().to_vec();
373        assert!(p.validate_proposed(&proposed, 0, &mems, 0).unwrap());
374    }
375
376    #[test]
377    fn validate_proposed_rejects_tampered_order() {
378        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
379        let mems = members(&[1, 2, 3]);
380        let _ = p.install_list(0, &mems, 3, 0).unwrap();
381        let mut tampered = p.current_list().unwrap().members().to_vec();
382        tampered.swap(0, 1);
383        assert!(!p.validate_proposed(&tampered, 0, &mems, 0).unwrap());
384    }
385
386    #[test]
387    fn steward_members_returns_filtered_roster() {
388        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
389        let mems = members(&[1, 2, 3]);
390        let _ = p.install_list(0, &mems, 3, 0).unwrap();
391        let dropped = mems[0].clone();
392        let filtered = p.steward_members(|c: &[u8]| c != dropped.as_slice());
393        assert_eq!(filtered.len(), 2);
394        assert!(filtered.iter().all(|m| m != &dropped));
395    }
396
397    #[test]
398    fn set_max_retries_updates_threshold() {
399        let mut p = DeterministicStewardList::empty(b"g".to_vec(), config());
400        p.set_max_retries(3);
401        assert_eq!(p.max_retries(), 3);
402
403        for _ in 0..3 {
404            assert!(p.bump_retry().is_empty());
405        }
406        let events = p.bump_retry();
407        assert_eq!(
408            events,
409            vec![StewardListEvent::RetryExhausted { round: 4, max: 3 }]
410        );
411    }
412
413    #[test]
414    fn election_proposer_walks_eligibility() {
415        let mut p =
416            DeterministicStewardList::empty(b"g".to_vec(), StewardListConfig::new(3, 3).unwrap());
417        let mems = members(&[1, 2, 3]);
418        let _ = p.install_list(0, &mems, 3, 0).unwrap();
419
420        let proposer = p.election_proposer(|_: &[u8]| true).unwrap().to_vec();
421        let next = p
422            .election_proposer(|c: &[u8]| c != proposer.as_slice())
423            .unwrap();
424        assert_ne!(next, proposer.as_slice());
425    }
426
427    /// When membership drops below `sn_min`, `maybe_auto_fill` regenerates
428    /// the steward list over the full member set with `retry_round = 0`.
429    /// RFC §"Steward list creation": every member must be a steward once
430    /// the conversation is below `sn_min`.
431    #[test]
432    fn maybe_auto_fill_installs_full_member_set_when_below_sn_min() {
433        let cfg = StewardListConfig::new(3, 5).unwrap();
434        let mut p = DeterministicStewardList::empty(b"g".to_vec(), cfg);
435        let mems = members(&[1, 2]); // below sn_min = 3
436
437        let events = p.maybe_auto_fill(5, &mems).unwrap();
438        assert_eq!(
439            events,
440            vec![StewardListEvent::ListInstalled {
441                epoch: 5,
442                retry_round: 0,
443                len: 2,
444            }]
445        );
446
447        let list = p.current_list().expect("auto-fill installed a list");
448        assert_eq!(list.len(), 2);
449        assert_eq!(list.retry_round(), 0);
450        for m in &mems {
451            assert!(list.contains(m), "auto-filled list must cover every member");
452        }
453    }
454
455    /// When membership is at or above `sn_min`, `maybe_auto_fill` is a
456    /// no-op: no events emitted and any existing list is left untouched.
457    #[test]
458    fn maybe_auto_fill_no_op_when_at_or_above_sn_min() {
459        let cfg = StewardListConfig::new(2, 5).unwrap();
460        let mut p = DeterministicStewardList::empty(b"g".to_vec(), cfg);
461        let mems = members(&[1, 2, 3]); // ≥ sn_min = 2
462
463        let events = p.maybe_auto_fill(0, &mems).unwrap();
464        assert!(events.is_empty(), "no-op above sn_min");
465        assert!(p.current_list().is_none(), "no list installed");
466    }
467}