# Messages are lost when two arrive out of order A group event that a relay hands back a moment late is dropped and cannot be recovered. Two messages published in the same second reliably lose one of them. This is a conformance gap in quartz's MLS implementation, not in this app. What this app can do about it from outside the library is partial, and is described at the end. ## Symptom The receiver stores the kind:445 group event and produces nothing from it. No inner event, no chat line, no error the user sees. `MarmotGroupEvent` is written *before* the message is decrypted, so the row survives while everything downstream of it silently does not: ``` receiver, room 6d8ec3ad ("Frosty (#admins)") 20:36:55 kind 9 chat message decrypted, applied 20:37:34 kind 30321 nonce decrypted, applied 20:37:34 kind 30320 proposal group event stored, no inner event ``` Both 20:37:34 events were published by the same sender in the same `proposeSigning` call. Every message that arrived on its own decrypted fine; the back-to-back pair lost exactly one. Downstream the failure reads as something else entirely. In the case above a FROST signing session never started on the receiver, because the proposal that opens one never arrived — leaving a nonce filed against a session that will never exist. An earlier instance of the same bug dropped a dialect, and the artifact referencing it then failed a foreign key and rolled back its whole transaction. ## Cause MLS is specified to tolerate out-of-order delivery inside an epoch. RFC 9420 §9.1: a receiver that gets generation `N+1` before `N` derives the intermediate keys and keeps them, so the older message can still be read when it turns up. Quartz does implement this. `SecretTree` caches them: ```kotlin // SecretTree.kt private val skippedKeys = mutableMapOf, KeyNonceGeneration>() fun applicationKeyNonceForGeneration(leafIndex: Int, generation: Int): KeyNonceGeneration { val cachedKey = skippedKeys.remove(Pair(leafIndex, generation)) if (cachedKey != null) { /* ...replay check... */ return cachedKey } val state = getOrInitSender(leafIndex) require(generation >= state.applicationGeneration) { "Generation $generation already consumed (current: ${state.applicationGeneration})" } ... } ``` The gap is that the cache is never persisted: ```kotlin // SecretTree.kt fun exportSenderStates(): Map = senderState.toMap() fun importSenderStates(states: Map) { senderState.putAll(states) } ``` `exportSenderStates()` returns the ratchet *positions* only. `MlsGroup.saveState()` calls it (`senderRatchetStates = secretTree.exportSenderStates()`) and `MlsGroup.restore()` calls `importSenderStates`. So `skippedKeys` exists only in one `SecretTree` instance's memory. That would be harmless if the group instance outlived the messages. It does not: `NostrDao` rebuilds it from stored state for every inbound event and saves it back afterwards. So the sequence is 1. generation 1 arrives, ratchet advances 0 → 2, generation 0's key goes into `skippedKeys` 2. `saveState()` — `skippedKeys` is dropped on the floor 3. generation 0 arrives, a fresh tree is restored with `applicationGeneration = 2`, the cache is empty, `require` fails 4. the exception is swallowed, the event yields no `ApplicationMessage` Step 3 is terminal. The key is derived from a ratchet that has moved past it and cannot be recovered, and nothing asks the sender to resend. Verified against the published artifact rather than a checkout: `quartz-1.14.0-sources.jar`, `commonMain/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt`. ## Why it is not an edge case here Nostr relays make no ordering guarantee at all, and negentropy reconciliation hands back a room's backlog in whatever order it likes. Any two messages close enough together can swap. Several flows publish in bursts, and each of them is a reliable trigger: | flow | messages in one pass | |---|---| | `FrostSigningManager.proposeSigning` | proposal, then the proposer's nonce | | `ChillDkgRitualManager.proposeRitual` | proposal, then the host key | | `MantraDao.addArtifact` | the artifact, then its first version | | `MantraDao.addChapter` | the chapter, then one per paragraph chunk | `addChapter` is the worst of these: a chapter with twenty paragraphs publishes twenty-one events at once, and only the ones that happen to arrive in ascending generation order survive. ## The fix, in quartz Carry the skipped keys through `saveState`/`restore` alongside the ratchet positions. **1. Export and import them.** In `SecretTree`: ```kotlin fun exportSkippedKeys(): Map, KeyNonceGeneration> = skippedKeys.toMap() fun importSkippedKeys(keys: Map, KeyNonceGeneration>) { skippedKeys.putAll(keys) } ``` `MAX_SKIPPED_KEYS` already bounds the map, so the serialised size is bounded by the same constant and needs no separate cap. **2. Put them in the group state.** `MlsGroup.saveState()` already writes `senderRatchetStates = secretTree.exportSenderStates()`; add a sibling field, and have `restore()` call `importSkippedKeys` next to its existing `importSenderStates`. **3. Keep old state readable.** The persisted state is a TLS-encoded struct that existing installs already hold, so the new field has to be optional: absent means an empty map, which is exactly the behaviour today. Without that, every device with a stored group is broken by the upgrade. **4. Consumed-generation replay protection.** `consumedGenerations` guards against a replayed message re-using a cached key. It is in-memory too, so it should travel with the skipped keys or the guard weakens across restarts. Worth deciding deliberately rather than by omission. A test worth having with it: save and restore a group between the two messages of an out-of-order pair, and assert the older one still decrypts. That is the property, and it is invisible to any test that keeps one instance alive. ### Getting the change into this build Quartz is **not** a local fork. It is `com.vitorpamplona.quartz:quartz`, pinned in `gradle/libs.versions.toml` and resolved from mavenCentral; `settings.gradle.kts` only `includeBuild`s `lightning-kmp-app`. Nothing in this repository can change it. There is a full amethyst clone at `~/Documents/development/nostr/amethyst` whose `SecretTree.kt` was byte-identical to published 1.14.0 when this was written, so the patch itself is a small delta against a known-good base. Landing it means one of: - **Upstream it.** It is a genuine RFC 9420 conformance gap and affects any client that reloads group state per message, which is the ordinary shape for a mobile app. Slowest, and the only option that leaves this repo's build reproducible. - **Patch the clone and publish to mavenLocal**, then add `mavenLocal()` here and pin the patched version. Fast, but the build then depends on a patched crypto library built from one machine's filesystem. - **Wire quartz as a composite build**, the way `lightning-kmp-app` is. Same coupling to a path outside the repo, but the source is at least visible. ## What this app does in the meantime `MlsGroupCache` keeps a room's `MlsGroup` instance alive between messages instead of rebuilding it from stored state each time, so `skippedKeys` survives for as long as the process does. The inbound path in `NostrDao` goes through it. This covers the case that actually bites — a burst arriving in one sync, decrypted one after another against the same tree — and it is what makes the flows in the table above work. It is not the fix, and it is worth being precise about what it leaves broken: - **A restart loses the cache.** Messages skipped before the app closed cannot be read after it reopens. - **Another writer invalidates it.** Sending a message advances the sender ratchet and saves the room's state; adding a member does too. The cache reuses its instance only while the stored state is still exactly what it last wrote, and rebuilds otherwise — dropping the skipped keys at that point, exactly as before. - **Nothing helps a long reorder.** A message the relay holds back until after a restart or an outbound send is gone. The staleness check is what keeps the cache from being *worse* than no cache: a group that has been overtaken by another writer is never carried on with, so the fallback is always the old behaviour rather than a diverged ratchet.