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.
This commit is contained in:
osmaczko
2026-08-24 18:23:34 +02:00
parent 147d0b3b88
commit 2a1ed26282
@@ -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.
///