Skip to main content

de_mls/conversation/
config.rs

1//! Per-conversation timing and policy configuration.
2
3use std::time::Duration;
4
5use crate::DEFAULT_MAX_RETRIES;
6use crate::ProposalKind;
7use crate::protos::de_mls::messages::v1::TimingConfig;
8
9/// Wall-clock window the steward waits before batching approved proposals
10/// into a commit (RFC §Inactivity Timer #1, "Commit inactivity").
11pub const DEFAULT_COMMIT_INACTIVITY_DURATION: Duration = Duration::from_secs(60);
12
13/// Lifetime of a voting proposal before it expires unvoted
14/// (RFC §Creating Voting Proposal).
15pub const DEFAULT_PROPOSAL_EXPIRATION: Duration = Duration::from_secs(600);
16
17/// Library deadline for a single consensus session — bounds how long a
18/// vote can stay open. MUST be `> voting_delay`.
19pub const DEFAULT_CONSENSUS_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// Inactivity window during Layer 2 / Layer 3 recovery
22/// (RFC §Inactivity Timer #2, "Recovery inactivity"). Typically shorter
23/// than `commit_inactivity_duration` so retries don't burn a full epoch.
24pub const DEFAULT_RECOVERY_INACTIVITY_DURATION: Duration = Duration::from_secs(5);
25
26/// Per-member window to cast a manual vote before the app auto-votes
27/// using `liveness_criteria_yes`. MUST be `< consensus_timeout`.
28pub const DEFAULT_VOTING_DELAY: Duration = Duration::from_secs(10);
29
30/// Auto-vote delay for steward-election proposals. Shorter than
31/// `DEFAULT_VOTING_DELAY` so recovery elections converge fast.
32pub const DEFAULT_ELECTION_VOTING_DELAY: Duration = Duration::from_secs(5);
33
34pub const DEFAULT_LIVENESS_CRITERIA_YES: bool = true;
35
36pub const DEFAULT_PENDING_UPDATE_MAX_EPOCHS: u32 = 3;
37
38/// Per-conversation timing config. Plug-in domains (scoring, steward list)
39/// own their own configs on the respective plug-ins — see
40/// [`crate::ScoringConfig`] and [`crate::StewardListConfig`].
41#[derive(Debug, Clone)]
42pub struct ConversationConfig {
43    /// RFC §Inactivity Timer #1: how long the epoch steward has to commit
44    /// approved proposals before honest members enter the freeze round.
45    pub commit_inactivity_duration: Duration,
46    /// Freeze window before deterministic selection.
47    ///
48    /// Defaults to `commit_inactivity_duration / 2`.
49    pub freeze_duration: Duration,
50    /// RFC §Inactivity Timer #2: shorter inactivity window applied during
51    /// Layer 2 / Layer 3 recovery so retries don't burn a full epoch.
52    pub recovery_inactivity_duration: Duration,
53    /// How long a proposal stays active before expiring (RFC §Creating Voting Proposal).
54    pub proposal_expiration: Duration,
55    pub consensus_timeout: Duration,
56    /// Max age (in epochs) of a buffered membership update. An entry first
57    /// seen at epoch `E` is dropped once `current_epoch - E` exceeds this
58    /// value (so it survives epochs `E..=E + max_age` inclusive).
59    pub pending_update_max_epochs: u32,
60    /// Max steward-election retries within one MLS epoch before the app
61    /// surfaces "reelection stuck". `0` disables retry entirely.
62    pub max_reelection_attempts: u32,
63    /// Per-member window to cast a manual vote before the app auto-casts
64    /// using `liveness_criteria_yes`. Relationship invariant:
65    /// `voting_delay < consensus_timeout < commit_inactivity_duration`. See
66    /// [`DEFAULT_VOTING_DELAY`].
67    pub voting_delay: Duration,
68    /// Auto-vote delay for steward-election proposals (see
69    /// [`DEFAULT_ELECTION_VOTING_DELAY`]).
70    pub election_voting_delay: Duration,
71    /// Whether silent voters count as YES at `consensus_timeout` (RFC
72    /// §Creating Voting Proposal). See [`DEFAULT_LIVENESS_CRITERIA_YES`].
73    /// Also used by the auto-vote timer as the cast value.
74    pub liveness_criteria_yes: bool,
75}
76
77impl Default for ConversationConfig {
78    fn default() -> Self {
79        Self {
80            commit_inactivity_duration: DEFAULT_COMMIT_INACTIVITY_DURATION,
81            freeze_duration: DEFAULT_COMMIT_INACTIVITY_DURATION / 2,
82            recovery_inactivity_duration: DEFAULT_RECOVERY_INACTIVITY_DURATION,
83            proposal_expiration: DEFAULT_PROPOSAL_EXPIRATION,
84            consensus_timeout: DEFAULT_CONSENSUS_TIMEOUT,
85            pending_update_max_epochs: DEFAULT_PENDING_UPDATE_MAX_EPOCHS,
86            max_reelection_attempts: DEFAULT_MAX_RETRIES,
87            voting_delay: DEFAULT_VOTING_DELAY,
88            election_voting_delay: DEFAULT_ELECTION_VOTING_DELAY,
89            liveness_criteria_yes: DEFAULT_LIVENESS_CRITERIA_YES,
90        }
91    }
92}
93
94impl ConversationConfig {
95    /// Auto-vote delay for the given proposal kind.
96    pub fn voting_delay_for(&self, kind: ProposalKind) -> Duration {
97        if kind.is_steward_election() {
98            self.election_voting_delay
99        } else {
100            self.voting_delay
101        }
102    }
103
104    /// Overwrite the duration fields from a wire [`TimingConfig`]. Used on
105    /// the joiner side when applying `ConversationSync`. Non-timing fields
106    /// (`liveness_criteria_yes`, `pending_update_max_epochs`) stay untouched.
107    pub fn apply_timing(&mut self, timing: &TimingConfig) {
108        apply_nonzero_ms(
109            &mut self.commit_inactivity_duration,
110            timing.commit_inactivity_duration_ms,
111        );
112        apply_nonzero_ms(&mut self.freeze_duration, timing.freeze_duration_ms);
113        apply_nonzero_ms(
114            &mut self.recovery_inactivity_duration,
115            timing.recovery_inactivity_duration_ms,
116        );
117        apply_nonzero_ms(&mut self.proposal_expiration, timing.proposal_expiration_ms);
118        apply_nonzero_ms(&mut self.consensus_timeout, timing.consensus_timeout_ms);
119    }
120}
121
122/// Overwrite `field` with `wire_ms` unless it is zero.
123/// A zero wire duration would make its timer fire immediately (a
124/// malformed-sync DoS); treat zero as "unset" and keep the local value.
125fn apply_nonzero_ms(field: &mut Duration, wire_ms: u64) {
126    if wire_ms != 0 {
127        *field = Duration::from_millis(wire_ms);
128    }
129}
130
131/// Build the wire [`TimingConfig`] from a [`ConversationConfig`]. Used on
132/// the steward side when sending `ConversationSync` to joiners.
133impl From<&ConversationConfig> for TimingConfig {
134    fn from(config: &ConversationConfig) -> Self {
135        Self {
136            commit_inactivity_duration_ms: config.commit_inactivity_duration.as_millis() as u64,
137            freeze_duration_ms: config.freeze_duration.as_millis() as u64,
138            recovery_inactivity_duration_ms: config.recovery_inactivity_duration.as_millis() as u64,
139            proposal_expiration_ms: config.proposal_expiration.as_millis() as u64,
140            consensus_timeout_ms: config.consensus_timeout.as_millis() as u64,
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn timing_config_round_trip() {
151        let original = ConversationConfig {
152            commit_inactivity_duration: Duration::from_millis(100),
153            freeze_duration: Duration::from_millis(200),
154            recovery_inactivity_duration: Duration::from_millis(300),
155            proposal_expiration: Duration::from_millis(400),
156            consensus_timeout: Duration::from_millis(500),
157            ..ConversationConfig::default()
158        };
159        let timing = TimingConfig::from(&original);
160        let mut applied = ConversationConfig::default();
161        applied.apply_timing(&timing);
162        assert_eq!(
163            applied.commit_inactivity_duration,
164            Duration::from_millis(100)
165        );
166        assert_eq!(applied.freeze_duration, Duration::from_millis(200));
167        assert_eq!(
168            applied.recovery_inactivity_duration,
169            Duration::from_millis(300)
170        );
171        assert_eq!(applied.proposal_expiration, Duration::from_millis(400));
172        assert_eq!(applied.consensus_timeout, Duration::from_millis(500));
173    }
174
175    #[test]
176    fn apply_timing_ignores_zero_durations() {
177        let mut config = ConversationConfig {
178            consensus_timeout: Duration::from_secs(30),
179            commit_inactivity_duration: Duration::from_secs(60),
180            ..ConversationConfig::default()
181        };
182        let timing = TimingConfig {
183            consensus_timeout_ms: 0,
184            commit_inactivity_duration_ms: 0,
185            freeze_duration_ms: 250,
186            recovery_inactivity_duration_ms: 0,
187            proposal_expiration_ms: 0,
188        };
189        config.apply_timing(&timing);
190        // Zero fields keep their prior values.
191        assert_eq!(config.consensus_timeout, Duration::from_secs(30));
192        assert_eq!(config.commit_inactivity_duration, Duration::from_secs(60));
193        // Non-zero field is applied.
194        assert_eq!(config.freeze_duration, Duration::from_millis(250));
195    }
196
197    #[test]
198    fn voting_delay_dispatch_on_proposal_kind() {
199        let config = ConversationConfig {
200            voting_delay: Duration::from_secs(7),
201            election_voting_delay: Duration::from_secs(3),
202            ..ConversationConfig::default()
203        };
204        assert_eq!(
205            config.voting_delay_for(ProposalKind::Commit),
206            Duration::from_secs(7)
207        );
208        assert_eq!(
209            config.voting_delay_for(ProposalKind::StewardElection),
210            Duration::from_secs(3)
211        );
212    }
213}