fix(marmot): surface inbound app messages even when LocalCache says duplicate

The B->Amethyst kind:445 reply was decrypting fine — the harness's
new outbound-side diagnostics + the Amethyst logcat showed a clean
ApplicationMessage path right up to the chatroom-add step:

    GroupEventHandler.add: kind:445 id=988221d0… groupId=ee27bc48…
    GroupEventHandler.add: ApplicationMessage decrypted innerKind=9
                            innerId=ebf35373… author=170df8d1…
    GroupEventHandler.add: inner event already in cache (duplicate)

`cache.justConsume` returned false because the inner event id was
already in LocalCache (a prior session persisted the same plaintext
via persistDecryptedMessage and the startup restore loop in
Account.kt:3034-3044 reloaded it into the cache before this kind:445
arrived). The live-receive branch in DecryptAndIndexProcessor was
gated on `isNew` and silently dropped the message — never calling
marmotGroupList.addMessage — so the chatroom never saw B's reply
even though decryption succeeded.

Mirror what the restore path does: always surface the inner note
in the chatroom. `MarmotGroupChatroom.addMessageSync` is itself
idempotent (dedupes by Note identity), so the call is safe even
when the message was already added through another path.
`persistDecryptedMessage` stays guarded on `isNew` because the
message store is append-only and a re-persist would grow the
on-disk log unboundedly across decrypt retries.
This commit is contained in:
Claude
2026-04-22 19:50:50 +00:00
parent bca829df30
commit 6ad522b52b
@@ -592,20 +592,41 @@ class GroupEventHandler(
"GroupEventHandler.add: ApplicationMessage decrypted innerKind=${innerEvent.kind} " +
"innerId=${innerEvent.id.take(8)}… author=${innerEvent.pubKey.take(8)}"
}
if (cache.justConsume(innerEvent, null, false)) {
val innerNote = cache.getOrCreateNote(innerEvent.id)
// `cache.justConsume` returns false if LocalCache already
// has this inner event id — typically because a previous
// session persisted the inner JSON and the startup
// restore loop in Account reloaded it into the cache
// before this kind:445 arrived. The chatroom may still
// not have it (e.g. the user's chatroom snapshot was
// dropped, or the restore-from-disk path raced ahead and
// the cache populated before the chatroom was hydrated).
// `addMessage` is itself idempotent (`addMessageSync`
// dedupes by Note identity), so always surface the note
// in the chatroom — otherwise the message is silently
// dropped and the operator sees only the "duplicate" log.
val isNew = cache.justConsume(innerEvent, null, false)
val innerNote = cache.getOrCreateNote(innerEvent.id)
if (isNew) {
innerNote.event = innerEvent
// Track the message in the Marmot group chatroom
account.marmotGroupList.addMessage(result.groupId, innerNote)
// Persist the decrypted plaintext so the message
// survives an app restart. Marmot/MLS application
// messages cannot be re-decrypted once the ratchet
// has advanced, so we must capture them here.
manager.persistDecryptedMessage(result.groupId, result.innerEventJson)
} else {
Log.d("MarmotDbg") { "GroupEventHandler.add: inner event already in cache (duplicate)" }
Log.d("MarmotDbg") {
"GroupEventHandler.add: inner event already in cache — surfacing in chatroom anyway"
}
}
// Track the message in the Marmot group chatroom
account.marmotGroupList.addMessage(result.groupId, innerNote)
// Persist the decrypted plaintext so the message
// survives an app restart. Marmot/MLS application
// messages cannot be re-decrypted once the ratchet
// has advanced, so we must capture them here. Skip
// when the cache reported duplicate — the message
// was already persisted on the run that originally
// decrypted it (the message store appends, so a
// re-persist would silently grow the on-disk log).
if (isNew) {
manager.persistDecryptedMessage(result.groupId, result.innerEventJson)
}
}