Skip to main content

de_mls/conversation/
handle.rs

1//! [`Conversation`] struct, constructor, and the state-machine + phase-timer
2//! coordinators that hold per-conversation protocol state, the MLS service,
3//! plug-ins, and durable config alongside [`crate::PhaseTimer`] under
4//! one lock. Per-conversation method bodies (proposal submission, voting,
5//! inbound dispatch, etc.) live in sibling modules and extend `Conversation`
6//! via additional `impl` blocks.
7
8use std::error::Error as StdError;
9use std::{
10    collections::HashMap,
11    sync::{Arc, Mutex},
12    time::{Duration, Instant},
13};
14
15use openmls_traits::storage::StorageProvider;
16use openmls_traits::{OpenMlsProvider, signatures::Signer};
17use prost::Message;
18use tracing::info;
19
20use hashgraph_like_consensus::events::ConsensusEventBus;
21
22use crate::{
23    BufferedCommitCandidate, ConsensusPlugin, ConsensusServiceFor, ConversationConfig,
24    ConversationError, ConversationEvent, ConversationQueues, ConversationState,
25    ConversationStateMachine, FreezeBufferOutcome, FreezeFinalizeResult, OperatingMode, Outbound,
26    PeerScoringPlugin, PhaseTimer, ProcessResult, ProposalKind, StewardListPlugin,
27    compute_commit_hash, decode_inbound_payload, finalize_freeze_round, member_set,
28    mls_crypto::{
29        CommitCandidate as MlsCommitCandidate, KeyPackageBytes, MlsCommitInput, MlsService,
30        OpenMlsService,
31    },
32    protos::de_mls::messages::v1::{
33        AppMessage, CommitCandidate, conversation_update_request::Payload,
34    },
35    replay_early_candidates,
36};
37
38/// Outcome of [`Conversation::leave`].
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LeaveOutcome {
41    /// A self-leave consensus round has been opened. The conversation stays
42    /// active until the next steward commit merges the removal.
43    LeaveInitiated,
44}
45
46/// Receiver type the conversation drains from `tick_deadlines`. Resolves to the
47/// `Receiver` associated type on the plugin's [`ConsensusEventBus`], which
48/// is bound to implement [`crate::SyncConsensusReceiver`].
49pub(crate) type ConsensusReceiver<C> = <<C as ConsensusPlugin>::EventBus as ConsensusEventBus<
50    <C as ConsensusPlugin>::Scope,
51>>::Receiver;
52
53/// One pending auto-vote: cast `vote` for `proposal_id` once the wall-clock
54/// catches up to `fire_at`. Registered by `initiate_proposal` (Deferred
55/// path) and `on_incoming_proposal`; cancelled on manual vote or consensus
56/// resolution; fired by [`Conversation::tick_deadlines`].
57#[derive(Debug, Clone, Copy)]
58pub struct AutoVoteEntry {
59    pub fire_at: Instant,
60    pub vote: bool,
61}
62
63/// The per-conversation MLS service, plug-in instances, and consensus wiring,
64/// grouped so the handle holds them as one unit. Construction builds the MLS
65/// service, moves the plug-in instances in, and subscribes the consensus
66/// receiver here.
67pub(crate) struct ConversationServices<C: ConsensusPlugin, Sc, St> {
68    /// Per-conversation MLS service. Present for the conversation's whole
69    /// lifetime: the creator seeds it at [`Conversation::create`], the joiner
70    /// at [`Conversation::join`].
71    pub(crate) mls: OpenMlsService,
72    /// Per-conversation peer-score plug-in.
73    pub(crate) scoring: Sc,
74    /// Per-conversation steward-list plug-in.
75    pub(crate) steward_list: St,
76    /// Per-conversation consensus service. Owns this conversation's scope
77    /// in the shared storage and a private event bus. Built from the
78    /// consensus service passed at construction.
79    pub(crate) consensus: ConsensusServiceFor<C>,
80    /// Subscriber on `consensus.event_bus()`. Drained by
81    /// `tick_deadlines`, which dispatches each event through
82    /// `apply_consensus_outcome`. Subscribed when the conversation is built in
83    /// [`Conversation::create`] / [`Conversation::join`].
84    pub(crate) consensus_rx: ConsensusReceiver<C>,
85}
86
87/// Time-driven conversation state walked once per polling cycle.
88pub(crate) struct Timing {
89    /// Wall-clock anchor combined with the state machine by coordinator methods.
90    pub(crate) phase_timer: PhaseTimer,
91    /// Pending auto-votes by `proposal_id`. Walked by
92    /// `tick_deadlines`; each entry whose `fire_at` has passed
93    /// gets a `cast_vote` and is removed from the map. Cancelled (removed)
94    /// when a manual vote arrives or the consensus session resolves.
95    pub(crate) pending_auto_votes: HashMap<u32, AutoVoteEntry>,
96    /// Pending consensus-session timeouts: `proposal_id -> fire_at`.
97    /// Registered when a proposal opens (own or incoming peer); fired by
98    /// `tick_deadlines` which calls `consensus.handle_consensus_timeout`.
99    /// Removed when the consensus session resolves naturally via `apply_consensus_outcome`.
100    pub(crate) pending_consensus_timeouts: HashMap<u32, Instant>,
101    /// Last freeze-progress snapshot emitted as `ConversationEvent::FreezeProgress`.
102    /// `poll()` compares the current `(received, expected)` against this and
103    /// emits a new event only when the count changes, avoiding repeated events
104    /// on consecutive polling ticks that observe the same progress. Reset to
105    /// `None` when the conversation leaves `Freezing`.
106    pub(crate) last_freeze_progress: Option<(usize, usize)>,
107    /// Anchor for the backup-steward proposal takeover: set when an
108    /// unproposed buffered membership update first appears with no live
109    /// proposal for it. A backup steward proposes the buffered update once
110    /// this is older than the recovery window — the epoch steward had its
111    /// chance and stayed silent. Cleared when the buffer is drained or nothing
112    /// is left to propose.
113    pub(crate) buffered_propose_anchor: Option<Instant>,
114}
115
116impl Timing {
117    /// Fresh time-driven state: a cleared phase timer, no pending votes or
118    /// timeouts, and no recorded freeze progress.
119    fn new() -> Self {
120        Self {
121            phase_timer: PhaseTimer::new(),
122            pending_auto_votes: HashMap::new(),
123            pending_consensus_timeouts: HashMap::new(),
124            last_freeze_progress: None,
125            buffered_propose_anchor: None,
126        }
127    }
128}
129
130pub struct Conversation<C: ConsensusPlugin, Sc: PeerScoringPlugin, St: StewardListPlugin> {
131    /// Conversation name. Identifies this conversation in the integrator's
132    /// registry and is used to construct scope keys for consensus operations.
133    /// Read via [`Conversation::conversation_id`].
134    pub(crate) conversation_id: String,
135    pub(crate) queues: ConversationQueues,
136    /// Per-conversation MLS service, plug-in instances, and consensus wiring.
137    pub(crate) services: ConversationServices<C, Sc, St>,
138    pub(crate) state_machine: ConversationStateMachine,
139    /// Per-conversation durable config: voting/consensus durations,
140    /// `liveness_criteria_yes`, `pending_update_max_epochs`.
141    pub(crate) config: ConversationConfig,
142    /// Authorization mode (RFC §Layer 3 Anti-Deadlock ECP).
143    operating_mode: OperatingMode,
144    /// Time-driven state walked once per polling cycle.
145    pub(crate) timing: Timing,
146    /// Identity bytes of the local member, snapshotted from the `member_id`
147    /// passed at construction. Read via [`Conversation::member_id_bytes`].
148    pub(crate) self_member_id: Arc<[u8]>,
149    /// Per-instance app id supplied at construction. Tagged on every
150    /// outbound packet and used for self-echo filtering in
151    /// [`Conversation::process_inbound`]. Read via [`Conversation::app_id`].
152    pub(crate) app_id: Arc<[u8]>,
153    /// Pending [`ConversationEvent`]s waiting for a caller to drain. Interior
154    /// `Mutex` so producer-side `emit_event` stays `&self`; consumers
155    /// drain via [`Self::drain_events`] once per polling cycle.
156    pending_events: Mutex<Vec<ConversationEvent>>,
157    /// Outbound the conversation produced, waiting for the integrator to
158    /// publish. The conversation never sends — it buffers here and the caller
159    /// drains via [`Self::drain_outbound`] once per cycle. Interior `Mutex`
160    /// so producer-side `broadcast` stays `&self`.
161    pending_outbound: Mutex<Vec<Outbound>>,
162}
163
164impl<C, Sc, St> Conversation<C, Sc, St>
165where
166    C: ConsensusPlugin,
167    Sc: PeerScoringPlugin,
168    St: StewardListPlugin,
169{
170    /// Build a fresh conversation around an already-assembled `services`
171    /// bundle (MLS service seeded, plug-ins configured, consensus receiver
172    /// subscribed). The time-driven `timing` state starts from defaults.
173    pub(crate) fn new(
174        conversation_id: String,
175        queues: ConversationQueues,
176        services: ConversationServices<C, Sc, St>,
177        state_machine: ConversationStateMachine,
178        config: ConversationConfig,
179        self_member_id: Arc<[u8]>,
180        app_id: Arc<[u8]>,
181    ) -> Self {
182        Self {
183            conversation_id,
184            queues,
185            services,
186            state_machine,
187            config,
188            operating_mode: OperatingMode::Normal,
189            timing: Timing::new(),
190            self_member_id,
191            app_id,
192            pending_events: Mutex::new(Vec::new()),
193            pending_outbound: Mutex::new(Vec::new()),
194        }
195    }
196
197    // ── Operating mode (Layer 3 Anti-Deadlock) ──────────────────────
198
199    pub fn is_in_recovery_mode(&self) -> bool {
200        self.operating_mode == OperatingMode::Recovery
201    }
202
203    pub fn enter_recovery_mode(&mut self) {
204        self.operating_mode = OperatingMode::Recovery;
205    }
206
207    pub fn exit_recovery_mode(&mut self) {
208        self.operating_mode = OperatingMode::Normal;
209    }
210
211    // ── State accessor ──────────────────────────────────────────────
212
213    pub fn current_state(&self) -> ConversationState {
214        self.state_machine.current_state()
215    }
216
217    // ── MLS service ─────────────────────────────────────────────────
218
219    /// Borrow the MLS service.
220    pub(crate) fn mls(&self) -> &OpenMlsService {
221        &self.services.mls
222    }
223
224    /// Mutably borrow the MLS service — required for the commit pipeline
225    /// and encrypt/decrypt methods that advance MLS state.
226    pub(crate) fn mls_mut(&mut self) -> &mut OpenMlsService {
227        &mut self.services.mls
228    }
229
230    // ── Protocol-function wrappers ─────────────────────────────────
231    //
232    // Read `queues`, `mls`, and `steward` from `self` so coordinator
233    // callsites don't destructure the conversation. Protocol logic lives in
234    // sibling `core` modules; these are pure delegation.
235
236    /// Build a commit candidate.
237    pub(crate) fn create_commit_candidate<Pr>(
238        &mut self,
239        provider: &Pr,
240        signer: &impl Signer,
241        self_member_id: &[u8],
242    ) -> Result<Option<Vec<u8>>, ConversationError>
243    where
244        Pr: OpenMlsProvider,
245        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
246    {
247        if !self.services.steward_list.is_steward(self_member_id) && !self.is_in_recovery_mode() {
248            return Err(ConversationError::NotASteward);
249        }
250
251        if self.queues.approved_proposals().is_empty() {
252            return Err(ConversationError::NoProposals);
253        }
254
255        // MLS forbids committing one's own removal. If the approved batch contains
256        // RemoveMember(self), skip local candidate creation — another steward will
257        // commit the batch (including this node's removal) once they enter freeze.
258        if self.queues.has_approved_removal(self_member_id) {
259            info!(
260                conversation = self.queues.name(),
261                "commit candidate skipped: approved batch contains self-remove"
262            );
263            return Ok(None);
264        }
265
266        // Governance proposals (emergency, election) are consensus-only and must
267        // not be in the approved queue at batch creation time.
268        let non_mls_ids: Vec<u32> = self
269            .queues
270            .approved_proposals()
271            .iter()
272            .filter(|(_, req)| ProposalKind::of(req).is_governance())
273            .map(|(&id, _)| id)
274            .collect();
275
276        if !non_mls_ids.is_empty() {
277            return Err(ConversationError::UnexpectedNonMlsProposals {
278                proposal_ids: non_mls_ids,
279            });
280        }
281
282        // Borrow `self.services.mls` directly so later `self.queues` reads stay
283        // a disjoint borrow.
284        let mls = &mut self.services.mls;
285
286        // Drop approved entries already reflected in conversation state (stale
287        // rebroadcast KPs, duplicate removes) — without this MLS would reject
288        // the whole batch with "Duplicate signature key in proposals and conversation".
289        let current_members = mls.members()?;
290        let current_members_set = member_set(&current_members);
291        let is_member = |id: &[u8]| current_members_set.contains(id);
292
293        // Urgent (ECP-driven) freeze: restrict the batch to just the target's
294        // RemoveMember. See `ConversationQueues::urgent_commit_target`.
295        let urgent_target = self.queues.urgent_commit_target().map(|t| t.to_vec());
296
297        // Iterate in insertion order (FIFO): library proposal IDs are
298        // content-derived hashes, so sort-by-id is not temporal.
299        let k_max = mls.commit_batch_max();
300        let approved = self.queues.approved_proposals();
301        let mut updates = Vec::with_capacity(approved.len().min(k_max));
302        // Joiners admitted by this batch, in Add order. Travels with the
303        // welcome so any holder can address delivery.
304        let mut joiner_identities = Vec::new();
305        for (_pid, proposal) in approved.iter().take(k_max) {
306            match proposal.payload.as_ref() {
307                Some(Payload::MemberInvite(im)) => {
308                    if urgent_target.is_some() {
309                        continue;
310                    }
311                    if is_member(&im.member_id) {
312                        continue;
313                    }
314                    updates.push(MlsCommitInput::Add(KeyPackageBytes::new(
315                        im.key_package_bytes.clone(),
316                        im.member_id.clone(),
317                    )));
318                    joiner_identities.push(im.member_id.clone());
319                }
320                Some(Payload::RemoveMember(rm)) => {
321                    if let Some(target) = urgent_target.as_deref()
322                        && rm.member_id != target
323                    {
324                        continue;
325                    }
326                    if !is_member(&rm.member_id) {
327                        continue;
328                    }
329                    updates.push(MlsCommitInput::Remove(rm.member_id.clone()));
330                }
331                _ => return Err(ConversationError::InvalidConversationUpdateRequest),
332            }
333        }
334
335        if updates.is_empty() {
336            return Ok(None);
337        }
338
339        let MlsCommitCandidate {
340            proposals: mls_proposals,
341            commit,
342            welcome,
343        } = mls.create_commit_candidate(provider, signer, &updates)?;
344
345        let candidate = CommitCandidate {
346            conversation_id: self.queues.name_bytes().to_vec(),
347            mls_proposals,
348            commit_message: commit,
349            steward_member_id: self_member_id.to_vec(),
350        };
351
352        // Welcome bytes are deferred until our merge so joiners can't
353        // advance epoch ahead of the steward.
354        let commit_hash = compute_commit_hash(&candidate.commit_message);
355        let epoch = mls.current_epoch()?;
356        let max_candidates = mls.members()?.len();
357        let outcome = self.queues.add_freeze_candidate(
358            BufferedCommitCandidate {
359                candidate_msg: candidate.clone(),
360                commit_hash,
361                is_local_candidate: true,
362                welcome_bytes: welcome,
363                joiner_identities,
364            },
365            epoch,
366            max_candidates,
367        );
368        // Non-Buffered outcomes are legitimate runtime states (see
369        // `FreezeBufferOutcome`), not errors — log at debug.
370        if !matches!(outcome, FreezeBufferOutcome::Buffered) {
371            tracing::debug!(
372                conversation = self.queues.name(),
373                epoch,
374                ?outcome,
375                "local commit candidate not buffered",
376            );
377        }
378
379        info!(
380            conversation = self.queues.name(),
381            epoch,
382            proposals = updates.len(),
383            "commit candidate created"
384        );
385
386        let candidate_msg: AppMessage = candidate.into();
387        Ok(Some(candidate_msg.encode_to_vec()))
388    }
389
390    /// Finalize the active freeze round.
391    pub(crate) fn finalize_freeze_round<Pr>(
392        &mut self,
393        provider: &Pr,
394        allow_subset_candidates: bool,
395        self_member_id: &[u8],
396    ) -> Result<FreezeFinalizeResult, ConversationError>
397    where
398        Pr: OpenMlsProvider,
399        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
400    {
401        let in_recovery = self.operating_mode == OperatingMode::Recovery;
402        let mls = &mut self.services.mls;
403        finalize_freeze_round(
404            provider,
405            &mut self.queues,
406            mls,
407            &self.services.steward_list,
408            in_recovery,
409            allow_subset_candidates,
410            self_member_id,
411        )
412    }
413
414    /// Re-buffer commit candidates stashed before their proposal was locally
415    /// approved. Call after applying a consensus outcome. No-op when nothing
416    /// is stashed.
417    pub(crate) fn replay_early_candidates(&mut self) -> Result<(), ConversationError> {
418        replay_early_candidates(&mut self.queues, &mut self.services.mls)
419    }
420
421    /// Decode an inbound app-subtopic payload into a [`ProcessResult`].
422    pub(crate) fn decode_inbound<Pr>(
423        &mut self,
424        provider: &Pr,
425        payload: &[u8],
426    ) -> Result<ProcessResult, ConversationError>
427    where
428        Pr: OpenMlsProvider,
429        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
430    {
431        decode_inbound_payload(provider, &mut self.queues, &mut self.services.mls, payload)
432    }
433
434    /// Append a [`ConversationEvent`] to the pending-events buffer. The caller's
435    /// polling cycle drains it via [`Self::drain_events`]. Stays `&self`
436    /// thanks to the interior [`Mutex`], so the many coordinator
437    /// methods that emit during a brief read guard don't need to escalate
438    /// to a write guard. Fire-and-forget (no `Result`), but a poisoned
439    /// buffer is logged rather than silently dropped.
440    pub(crate) fn emit_event(&self, event: ConversationEvent) {
441        match self.pending_events.lock() {
442            Ok(mut buf) => buf.push(event),
443            Err(_) => {
444                tracing::error!(?event, "event buffer mutex poisoned; event dropped")
445            }
446        }
447    }
448
449    /// Drain every pending [`ConversationEvent`] accumulated since the last
450    /// call. Returns events in insertion order. Callers (UI fanout,
451    /// audit log) invoke this once per polling cycle.
452    pub fn drain_events(&self) -> Vec<ConversationEvent> {
453        match self.pending_events.lock() {
454            Ok(mut buf) => std::mem::take(&mut *buf),
455            Err(_) => {
456                tracing::error!("event buffer mutex poisoned; UI fanout will miss events");
457                Vec::new()
458            }
459        }
460    }
461
462    /// Smallest pending deadline relative to now, or `None` when nothing
463    /// is scheduled. Returns `Some(Duration::ZERO)` for an already-elapsed
464    /// deadline. Covers consensus-session timeouts, auto-vote timers, and
465    /// state-machine phase deadlines (Freezing window, steward / recovery
466    /// inactivity). Forward to an external scheduler that calls `poll()` on
467    /// fire; extra/early wakeups are no-ops.
468    pub fn next_wakeup_in(&self) -> Option<Duration> {
469        let now = Instant::now();
470        let earliest = self
471            .timing
472            .pending_consensus_timeouts
473            .values()
474            .copied()
475            .chain(self.timing.pending_auto_votes.values().map(|e| e.fire_at))
476            .chain(self.phase_deadline())
477            .min()?;
478        Some(earliest.saturating_duration_since(now))
479    }
480
481    /// State-driven phase-timer deadline, if one is currently active. The
482    /// conversation's polling paths (the freeze step in `poll`, and the
483    /// inactivity check in `check_steward_inactivity`) all gate on the phase
484    /// timer; this surfaces the same wall-clock target so an external
485    /// scheduler can wake us at the right time.
486    fn phase_deadline(&self) -> Option<Instant> {
487        let anchor = self.timing.phase_timer.started_at()?;
488        let cfg = &self.config;
489        match self.current_state() {
490            ConversationState::Freezing => Some(anchor + cfg.freeze_duration),
491            ConversationState::Working => {
492                if self.queues.approved_proposals_count() == 0 {
493                    return None;
494                }
495                let dur = if self.is_in_recovery_mode() {
496                    cfg.recovery_inactivity_duration
497                } else {
498                    cfg.commit_inactivity_duration
499                };
500                Some(anchor + dur)
501            }
502            _ => None,
503        }
504    }
505
506    /// Buffer encrypted conversation traffic (chat, votes, sync, commit
507    /// candidates) as an [`Outbound`], stamped with this conversation and the
508    /// local sender. Stays `&self` thanks to the interior `Mutex`, so send
509    /// sites in `&self` methods don't escalate to a write guard. The conversation
510    /// never sends — the caller drains via [`Self::drain_outbound`].
511    pub(crate) fn broadcast(&self, payload: Vec<u8>) {
512        let out = Outbound {
513            conversation_id: self.conversation_id.clone(),
514            sender: self.app_id.to_vec(),
515            payload,
516        };
517        match self.pending_outbound.lock() {
518            Ok(mut buf) => buf.push(out),
519            Err(_) => {
520                tracing::error!("outbound buffer mutex poisoned; item dropped")
521            }
522        }
523    }
524
525    /// Drain every buffered [`Outbound`] accumulated since the last call,
526    /// in insertion order. The integrator invokes this once per cycle (after
527    /// `poll` / `handle_inbound` / an intent) and maps each item onto its
528    /// own transport.
529    pub fn drain_outbound(&self) -> Vec<Outbound> {
530        match self.pending_outbound.lock() {
531            Ok(mut buf) => std::mem::take(&mut *buf),
532            Err(_) => {
533                tracing::error!("outbound buffer mutex poisoned; integrator will miss outbound");
534                Vec::new()
535            }
536        }
537    }
538
539    // ── Pending deadlines (auto-votes + consensus timeouts) ─────────
540
541    /// Register an auto-vote to fire `delay` from now with the given
542    /// `vote` choice. Idempotent — re-registering for the same
543    /// `proposal_id` replaces the existing entry.
544    pub(crate) fn register_auto_vote(&mut self, proposal_id: u32, delay: Duration, vote: bool) {
545        self.timing.pending_auto_votes.insert(
546            proposal_id,
547            AutoVoteEntry {
548                fire_at: Instant::now() + delay,
549                vote,
550            },
551        );
552    }
553
554    /// Drop the pending auto-vote for `proposal_id` if any is registered.
555    /// Called when a manual vote arrives (manual choice wins) or when the
556    /// consensus session resolves (vote no longer meaningful).
557    pub(crate) fn cancel_auto_vote(&mut self, proposal_id: u32) {
558        self.timing.pending_auto_votes.remove(&proposal_id);
559    }
560
561    /// Drop every pending auto-vote on this conversation. Called on every path that
562    /// emits `Leaving` so no stale entries fire against a conversation we've
563    /// left.
564    pub(crate) fn cancel_all_auto_votes(&mut self) {
565        self.timing.pending_auto_votes.clear();
566    }
567
568    /// Leave this conversation. Opens a self-leave consensus round and returns
569    /// [`LeaveOutcome::LeaveInitiated`]; the leave completes when the next
570    /// steward commit merges the removal. `signer` is the local member's MLS
571    /// signer, used to authenticate the self-leave proposal.
572    pub fn leave<Pr>(
573        &mut self,
574        provider: &Pr,
575        signer: &impl Signer,
576    ) -> Result<LeaveOutcome, ConversationError>
577    where
578        Pr: OpenMlsProvider,
579        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
580    {
581        self.initiate_self_leave(provider, signer)?;
582        Ok(LeaveOutcome::LeaveInitiated)
583    }
584
585    /// Register a consensus-session timeout. Fires `delay` from now via
586    /// `tick_deadlines`; removed naturally on consensus resolution.
587    pub(crate) fn register_consensus_timeout(&mut self, proposal_id: u32, delay: Duration) {
588        self.timing
589            .pending_consensus_timeouts
590            .insert(proposal_id, Instant::now() + delay);
591    }
592
593    /// Drop the pending consensus timeout for `proposal_id`. Called from
594    /// `apply_consensus_outcome` once the library reaches/fails consensus,
595    /// so the timeout can't fire a stale `handle_consensus_timeout` against
596    /// an already-resolved consensus session.
597    pub(crate) fn unregister_consensus_timeout(&mut self, proposal_id: u32) {
598        self.timing.pending_consensus_timeouts.remove(&proposal_id);
599    }
600
601    // ── State-machine + phase-timer coordinators ────────────────────
602
603    pub(crate) fn start_working(&mut self) -> ConversationState {
604        self.state_machine.start_working();
605        self.timing.phase_timer.clear();
606        info!(state = "Working", "state transition");
607        ConversationState::Working
608    }
609
610    /// Enter `Freezing` from `Working` or `Reelection`, starting the freeze
611    /// phase timer. Returns `Some(Freezing)` on transition; `None` (no-op)
612    /// from any other state.
613    pub(crate) fn start_freezing(&mut self) -> Option<ConversationState> {
614        if self.state_machine.start_freezing() {
615            self.timing.phase_timer.start();
616            info!(state = "Freezing", "state transition");
617            Some(ConversationState::Freezing)
618        } else {
619            None
620        }
621    }
622
623    pub(crate) fn start_selection(&mut self) -> ConversationState {
624        self.state_machine.start_selection();
625        info!(state = "Selection", "state transition");
626        ConversationState::Selection
627    }
628
629    pub(crate) fn start_reelection(&mut self) -> ConversationState {
630        self.state_machine.start_reelection();
631        self.timing.phase_timer.clear();
632        info!(state = "Reelection", "state transition");
633        ConversationState::Reelection
634    }
635
636    /// `true` once the freeze window elapsed while in `Freezing`.
637    pub(crate) fn is_freeze_timed_out(&self) -> bool {
638        self.current_state() == ConversationState::Freezing
639            && self
640                .timing
641                .phase_timer
642                .elapsed_since_anchor(self.config.freeze_duration)
643    }
644
645    /// Drives the "steward waited too long to commit" transition into
646    /// `Freezing`. Call each poll tick. Returns `Some(Freezing)` exactly
647    /// on the tick that transitions; `None` while still waiting, outside
648    /// Working, or when there's no approved work. Self-starts the
649    /// inactivity anchor on the first tick with approved work.
650    pub(crate) fn check_steward_inactivity(
651        &mut self,
652        approved_proposals_count: usize,
653        inactivity_duration: Duration,
654    ) -> Option<ConversationState> {
655        if self.current_state() != ConversationState::Working || approved_proposals_count == 0 {
656            return None;
657        }
658        if self.timing.phase_timer.started_at().is_none() {
659            self.timing.phase_timer.start();
660            info!(
661                approved = approved_proposals_count,
662                inactivity_ms = inactivity_duration.as_millis() as u64,
663                "inactivity timer started"
664            );
665            return None;
666        }
667        if !self
668            .timing
669            .phase_timer
670            .elapsed_since_anchor(inactivity_duration)
671        {
672            return None;
673        }
674        info!(
675            inactivity_ms = inactivity_duration.as_millis() as u64,
676            approved = approved_proposals_count,
677            "inactivity window elapsed, entering freeze"
678        );
679        self.start_freezing()
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use std::time::Instant;
686
687    use openmls_basic_credential::SignatureKeyPair;
688
689    use super::*;
690    use crate::ConversationQueues;
691    use crate::defaults::DefaultConsensusPlugin;
692    use crate::test_fixtures::{
693        StubScoring, StubStewardList, TestMls, TestProvider, make_creator_mls,
694        make_test_consensus_service,
695    };
696
697    type TestConversation = Conversation<DefaultConsensusPlugin, StubScoring, StubStewardList>;
698
699    /// Build a conversation with a real creator-side MLS service and the given
700    /// steward stub, returning it alongside the provider that backs the MLS
701    /// group and the signer that seeded it (for paths that sign — the guard
702    /// tests early-return before then).
703    fn make_conversation_with_steward(
704        steward_list: StubStewardList,
705    ) -> (TestConversation, TestProvider, SignatureKeyPair) {
706        let (mls, provider, signer) = make_creator_mls(b"test-member-id");
707        (build_conversation(mls, steward_list), provider, signer)
708    }
709
710    fn make_conversation_working() -> TestConversation {
711        let (mls, _provider, _signer) = make_creator_mls(b"test-member-id");
712        build_conversation(mls, StubStewardList::member())
713    }
714
715    fn build_conversation(mls: TestMls, steward_list: StubStewardList) -> TestConversation {
716        let (consensus, consensus_rx) = make_test_consensus_service();
717        Conversation::new(
718            "g".to_string(),
719            ConversationQueues::new("g"),
720            ConversationServices {
721                mls,
722                scoring: StubScoring,
723                steward_list,
724                consensus,
725                consensus_rx,
726            },
727            ConversationStateMachine::new_as_member(),
728            ConversationConfig::default(),
729            Arc::from(&b"test-member-id"[..]),
730            Arc::from(&[0u8; 16][..]),
731        )
732    }
733
734    /// First tick with approved work auto-anchors the timer and returns `None`.
735    /// Second tick before timeout still returns `None`. State must remain Working.
736    #[test]
737    fn check_steward_inactivity_first_tick_anchors_and_returns_none() {
738        let mut conversation = make_conversation_working();
739        assert_eq!(conversation.current_state(), ConversationState::Working);
740        assert!(
741            conversation.timing.phase_timer.started_at().is_none(),
742            "fresh conversation has no anchor"
743        );
744
745        let result =
746            conversation.check_steward_inactivity(/* approved */ 1, Duration::from_secs(10));
747
748        assert_eq!(result, None, "first tick auto-anchors and returns None");
749        assert!(
750            conversation.timing.phase_timer.started_at().is_some(),
751            "anchor must be set after first tick"
752        );
753        assert_eq!(
754            conversation.current_state(),
755            ConversationState::Working,
756            "state must stay Working until inactivity actually elapses"
757        );
758
759        let result =
760            conversation.check_steward_inactivity(/* approved */ 1, Duration::from_secs(10));
761        assert_eq!(
762            result, None,
763            "second tick before timeout still returns None"
764        );
765    }
766
767    /// No approved work → no anchor started, no transition.
768    #[test]
769    fn check_steward_inactivity_noop_without_approved_work() {
770        let mut conversation = make_conversation_working();
771        let result = conversation.check_steward_inactivity(0, Duration::from_secs(10));
772        assert_eq!(result, None);
773        assert!(
774            conversation.timing.phase_timer.started_at().is_none(),
775            "no approved work must not start the timer"
776        );
777    }
778
779    // ── Caller-polled deadlines + drain model ───────────────────────────
780
781    /// `emit_event` appends and `drain_events` returns insertion-ordered.
782    /// Establishes the contract relied on by integration tests that build
783    /// up an event log over multiple polling cycles.
784    #[test]
785    fn emit_event_then_drain_returns_insertion_order_and_clears_buffer() {
786        let conversation = make_conversation_working();
787        conversation.emit_event(ConversationEvent::PhaseChange(ConversationState::Working));
788        conversation.emit_event(ConversationEvent::Leaving);
789
790        let drained = conversation.drain_events();
791        assert_eq!(drained.len(), 2);
792        assert!(matches!(
793            drained[0],
794            ConversationEvent::PhaseChange(ConversationState::Working)
795        ));
796        assert!(matches!(drained[1], ConversationEvent::Leaving));
797
798        // Second drain returns empty — buffer was cleared.
799        assert!(conversation.drain_events().is_empty());
800    }
801
802    /// `register_auto_vote` is idempotent — re-registering the same
803    /// `proposal_id` replaces the previous entry rather than stacking.
804    /// Caller relies on this when re-anchoring an auto-vote on a `Deferred`
805    /// re-submit.
806    #[test]
807    fn register_auto_vote_replaces_existing_entry() {
808        let mut conversation = make_conversation_working();
809        conversation.register_auto_vote(7, Duration::from_secs(10), true);
810        let first_fire = conversation.timing.pending_auto_votes[&7].fire_at;
811
812        // Re-register with a different `vote` and a longer delay; the
813        // second insert must overwrite, not co-exist.
814        std::thread::sleep(Duration::from_millis(2));
815        conversation.register_auto_vote(7, Duration::from_secs(20), false);
816        assert_eq!(conversation.timing.pending_auto_votes.len(), 1);
817        let entry = conversation.timing.pending_auto_votes[&7];
818        assert!(!entry.vote);
819        assert!(entry.fire_at > first_fire);
820    }
821
822    /// `cancel_auto_vote` drops one entry. `cancel_all_auto_votes` drops
823    /// every entry. Both are the only paths that should remove pending
824    /// auto-votes from outside `tick_deadlines`.
825    #[test]
826    fn cancel_auto_vote_removes_only_the_targeted_proposal() {
827        let mut conversation = make_conversation_working();
828        conversation.register_auto_vote(1, Duration::from_secs(5), true);
829        conversation.register_auto_vote(2, Duration::from_secs(5), false);
830        conversation.register_auto_vote(3, Duration::from_secs(5), true);
831
832        conversation.cancel_auto_vote(2);
833        assert!(conversation.timing.pending_auto_votes.contains_key(&1));
834        assert!(!conversation.timing.pending_auto_votes.contains_key(&2));
835        assert!(conversation.timing.pending_auto_votes.contains_key(&3));
836
837        conversation.cancel_all_auto_votes();
838        assert!(conversation.timing.pending_auto_votes.is_empty());
839    }
840
841    /// `register_consensus_timeout` records `now + delay`;
842    /// `unregister_consensus_timeout` drops it. `apply_consensus_outcome`
843    /// uses the unregister path to drop deadlines on natural resolution
844    /// so `tick_deadlines` doesn't fire a stale `handle_consensus_timeout`.
845    #[test]
846    fn register_then_unregister_consensus_timeout() {
847        let mut conversation = make_conversation_working();
848        let before = Instant::now();
849        conversation.register_consensus_timeout(42, Duration::from_secs(30));
850        let fire_at = conversation.timing.pending_consensus_timeouts[&42];
851        assert!(fire_at > before + Duration::from_secs(29));
852        assert!(fire_at < Instant::now() + Duration::from_secs(31));
853
854        conversation.unregister_consensus_timeout(42);
855        assert!(
856            !conversation
857                .timing
858                .pending_consensus_timeouts
859                .contains_key(&42)
860        );
861
862        // Unregistering an unknown id is a no-op (no panic, no error).
863        conversation.unregister_consensus_timeout(999);
864    }
865
866    // ── create_commit_candidate guards ──────────────────────────────────
867
868    #[test]
869    fn create_commit_candidate_errors_for_non_steward_outside_recovery() {
870        let (mut conversation, provider, signer) =
871            make_conversation_with_steward(StubStewardList::member());
872        let err = conversation
873            .create_commit_candidate(&provider, &signer, b"me")
874            .expect_err("non-steward should be rejected");
875        assert!(matches!(err, ConversationError::NotASteward));
876    }
877
878    #[test]
879    fn create_commit_candidate_errors_when_no_approved_proposals() {
880        let (mut conversation, provider, signer) =
881            make_conversation_with_steward(StubStewardList::steward());
882        let err = conversation
883            .create_commit_candidate(&provider, &signer, b"me")
884            .expect_err("empty approved queue should be rejected");
885        assert!(matches!(err, ConversationError::NoProposals));
886    }
887
888    /// An emergency-criteria proposal in the approved queue must surface as
889    /// `UnexpectedNonMlsProposals` — only MLS-producing payloads belong in a
890    /// commit. The error carries the offending proposal ids so the
891    /// orchestrator can drop them.
892    #[test]
893    fn create_commit_candidate_errors_on_emergency_in_approved_queue() {
894        use crate::protos::de_mls::messages::v1::ViolationEvidence;
895
896        let (mut conversation, provider, signer) =
897            make_conversation_with_steward(StubStewardList::steward());
898        let emergency = ViolationEvidence::broken_commit(vec![0xAA], 0, Vec::<u8>::new())
899            .with_creator(vec![0x01])
900            .into_update_request()
901            .unwrap();
902        conversation.queues.insert_approved_proposal(50, emergency);
903
904        let err = conversation
905            .create_commit_candidate(&provider, &signer, b"me")
906            .expect_err("emergency in approved queue should be rejected");
907        let ConversationError::UnexpectedNonMlsProposals { proposal_ids } = err else {
908            panic!("expected UnexpectedNonMlsProposals, got {err:?}");
909        };
910        assert_eq!(proposal_ids, vec![50]);
911    }
912}