chore(marmot): demote benign inbound failures from WARN to DEBUG

Two kinds of noisy warnings appear every time an invitee or a
restarted client catches up with events still sitting on the relays.
Both are actually expected, per-spec behavior — not errors.

(a) Outer ChaCha20-Poly1305 decrypt fails with "current and N
    retained epoch key(s)".

    A newly-joined member processes the three kind:445s that
    advanced the group to their current epoch (add-me-commit plus
    any earlier commits). The commits were outer-encrypted with
    keys that predate their Welcome, so they never held them — this
    is MLS forward secrecy working as intended. No state on the
    receiver's side actually needs to update: they're already at
    the post-Welcome epoch.

(b) "NO matching KeyPackageBundle for eventId=…" after app restart.

    The gift-wrapped Welcome (kind:1059) is still on the relay and
    gets redelivered on every subscription refresh. The client's
    `processedEventIds` dedup set is in-memory, so after a restart
    the Welcome flows all the way to `processWelcome`, which then
    fails to find the KeyPackage bundle because it was consumed and
    marked during the first processing.

Both of these fire every time a member opens the app, so the logs
currently look like something is broken even when everything is
behaving correctly.

Fix:

- Add `GroupEventResult.UndecryptableOuterLayer(groupId,
  retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent`
  and `applyCommit` now call the new `tryDecryptOuterLayer` (which
  returns `ByteArray?` instead of throwing) and surface this
  variant when every available key fails. `GroupEventHandler.add`
  logs it at DEBUG with a one-liner noting "likely from before our
  join".

- Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When
  `MarmotInboundProcessor.processWelcome` is called with a
  `hintNostrGroupId` we're already a member of, short-circuit
  before the KeyPackageBundle lookup and return `AlreadyJoined`.
  `processMarmotWelcomeFlow` logs at DEBUG.

- `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer`
  as a no-op for subscription-timestamp updates (same as
  Duplicate/Error/CommitPending), so a unreadable past-epoch event
  doesn't move the group's `since` forward.

No behavioral changes beyond logging level and the short-circuit of
a Welcome we already processed (which was throwing and returning
Error before). Existing callers that only care about successful
`Joined` / successful `CommitProcessed` / `ApplicationMessage`
branches are unaffected — the two new variants join the
`Duplicate` / `CommitPending` / `Error` quiet side of the sealed
class.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
This commit is contained in:
Claude
2026-04-21 02:56:52 +00:00
parent 7ee5f64fd8
commit 96cedcbc9b
3 changed files with 98 additions and 15 deletions
@@ -404,6 +404,15 @@ private suspend fun processMarmotWelcomeFlow(
}
}
is WelcomeResult.AlreadyJoined -> {
// Benign replay of a gift-wrapped Welcome (kind:1059) we already
// processed in a prior session — the relay is just re-delivering
// it after app restart. Log at DEBUG, not WARN.
Log.d("MarmotDbg") {
"processMarmotWelcomeFlow: already joined group=${result.nostrGroupId.take(8)}… — treating Welcome as replay"
}
}
is WelcomeResult.Error -> {
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" }
}
@@ -619,6 +628,16 @@ class GroupEventHandler(
Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}" }
}
is GroupEventResult.UndecryptableOuterLayer -> {
// Expected for commits + application messages from epochs
// that predate our join — per MLS forward secrecy we
// never held those keys. Not a bug, not a warning.
Log.d("MarmotDbg") {
"GroupEventHandler.add: undecryptable outer layer for group=${result.groupId.take(8)}" +
"(current + ${result.retainedEpochCount} retained epoch key(s) tried) — likely from before our join"
}
}
is GroupEventResult.Error -> {
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
}