Skip to main content

de_mls/core/conversation/
config.rs

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