fix(marmot): short-circuit past/future PrivateMessage commits

Extend the past-epoch dedup check to PrivateMessage commits too.
Previously only the PublicMessage branch returned
`GroupEventResult.Duplicate` for a stale commit echo; the PrivateMessage
branch called `groupManager.decrypt()` directly, which consumed a
generation on the sender's ratchet and then failed with
`Message epoch X doesn't match current epoch Y` — polluting the harness
log and (worse) burning the real commit's generation slot.

Peek the epoch from the parsed PrivateMessage before touching the
secret tree: past-epoch → Duplicate, future-epoch → Error, same-epoch
falls through to the existing decrypt-and-dispatch path.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
This commit is contained in:
Claude
2026-04-22 03:59:04 +00:00
parent e9df0155c1
commit 647acb5909
@@ -511,16 +511,37 @@ class MarmotInboundProcessor(
when (mlsMessage.wireFormat) { when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> { WireFormat.PRIVATE_MESSAGE -> {
// For private commits, MLS decrypt handles epoch advancement // Sniff the PrivateMessage epoch without consuming any
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes()) // ratchet state. Past-epoch echoes and future-epoch
if (decrypted.contentType == ContentType.COMMIT) { // arrivals must not advance the secret tree — otherwise
val group = groupManager.getGroup(groupId) // the real handshake / application message gets rejected
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) // when it finally arrives.
} else { val privPeek = PrivateMessage.decodeTls(TlsReader(mlsMessage.payload))
GroupEventResult.Error( val currentEpoch = groupManager.getGroup(groupId)?.epoch
groupId, when {
"Expected COMMIT but got ${decrypted.contentType}", currentEpoch != null && privPeek.epoch < currentEpoch -> {
) GroupEventResult.Duplicate(groupId)
}
currentEpoch != null && privPeek.epoch > currentEpoch -> {
GroupEventResult.Error(
groupId,
"PrivateMessage epoch ${privPeek.epoch} is ahead of local epoch $currentEpoch; ignoring",
)
}
else -> {
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
if (decrypted.contentType == ContentType.COMMIT) {
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
} else {
GroupEventResult.Error(
groupId,
"Expected COMMIT but got ${decrypted.contentType}",
)
}
}
} }
} }