From 2a1ed262827483b7cd87d2d52ef92aa126429135 Mon Sep 17 00:00:00 2001 From: osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:36:07 +0200 Subject: [PATCH] feat: name the group state each member holds A GroupV2 group can divide into branches that never converge again, and no member can tell from its own view that it happened: both halves keep a roster and an epoch number, each simply stops being able to read what the other sends, and a fresh split leaves both carrying the same members. A member that has merely fallen behind produces the same endless run of undecryptable frames, from a different cause needing a different fix, and nothing separates the two. MLS derives a value for exactly this comparison, meant to be compared out of band: at one epoch number, different epoch authenticators are a fork and identical ones are real convergence. Write it once per epoch a conversation reaches, so a run leaves behind the one thing that partitions its members. --- .../src/conversation/group_v2.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index 95951bb..5d5c11d 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -226,6 +226,7 @@ impl GroupV2Convo { convo.init(service_ctx)?; convo.after_op(service_ctx)?; + convo.log_epoch(); Ok(convo) } @@ -262,6 +263,7 @@ impl GroupV2Convo { convo.init(service_ctx)?; // subscribe convo.after_op(service_ctx)?; // flush join broadcast + schedule wakeup + convo.log_epoch(); Ok(convo) } @@ -490,6 +492,12 @@ impl GroupV2Convo { }; service_ctx.group_v2_status.record(&self.convo_id, kind); } + if events + .iter() + .any(|evt| matches!(evt, ConversationEvent::CommitApplied(_))) + { + self.log_epoch(); + } // 3. Publish for out in outbound { @@ -520,6 +528,26 @@ impl GroupV2Convo { Ok(events) } + /// Names this member's view of the group's state. Two members at the same + /// epoch holding different authenticators have forked; a member at a lower + /// epoch has only fallen behind. The pair carries that meaning only + /// compared across members, never on one alone. + fn log_epoch(&self) { + let epoch = match self.conversation.epoch_and_retry() { + Ok((epoch, _)) => epoch, + Err(e) => { + tracing::warn!(convo = %self.convo_id, error = %e, "epoch unavailable"); + return; + } + }; + info!( + convo = %self.convo_id, + epoch, + authenticator = %hex::encode(self.conversation.epoch_authenticator()), + "epoch reached" + ); + } + /// Turn drained de-mls events into a [`ConvoOutcome`], unwrapping the /// message from its causal-history envelope. ///