Files
mantra-kmp/composeApp
Kgothatso Ngako 9107b81c99 fix: hand the notary the whole queue, not the one row standing at its head
A message sent into a marmot group sometimes sticks on the unsealed icon and
never leaves. It is not that message that is broken. Something ahead of it in
the queue cannot be sent, and because the notary was handed one row at a time,
that row was the queue.

**The queue.** MarmotInnerEventDao.observeUnprocessedMarmotInnerEvents selected
every unsent row for a key and returned `Flow<MarmotInnerEvent?>`, so Room
handed back the first one and dropped the rest:

    SELECT * FROM MarmotInnerEvent
    WHERE publicKey = :publicKey AND marmotGroupEventId IS NULL
    ORDER BY createdAt ASC

The only exit from that queue is a successful send.
MarmotOutboundDao.encryptAndSendMarmotInnerEvent stamps the row with the group
event it became, inside the same @Transaction that writes the event, the
NostrEvent and the BroadcastNostrEventRequests. Nothing else clears
`marmotGroupEventId`, there is no attempt count, no failure status and no
sweep. A row that cannot be sent therefore does not move, and it is selected
again, and again, at the head of every later emission.

Note what the filter is: the sender's key, not the room. One room whose MLS
state is gone silences every group on the device.

**The stopper.** DatabaseMarmotRepository.encryptAndSendMarmotInnerEvent was two
nested `?.let`:

    database.chatRoomDao().findChatRoomById(...)?.let { localChatRoom ->
        localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup ->
            ...
        }
    }

A row for a room this device holds no MLS state for -- the shape a room
restored from an inbound gift wrap has -- fell out of both and returned. No
send, no throw, no log, and no mark on the row. So the queue manufactured its
own permanent head: a message that could never succeed, reported as though
nothing had happened, sitting in front of everything else forever.
MarmotOutboundDao.inviteMemberToChatRoom already throws
MarmotMissingChatGroupException on exactly this condition, with a comment
saying the point is to "say so instead of silently doing nothing and letting
the caller report success". The send path disagreed with the invite path about
the same missing group.

**distinctUntilChanged.** Both notary collectors sat behind it, which is the
wrong question to ask a work queue. A queue re-emits because its table changed;
that is the signal to look again, not a duplicate to discard. Comparing an
emission against the previous one asks "is this new work?" when the question is
"is there work left?".

The two queues then failed by different mechanics, which is worth writing down
because it explains why the symptom looks like a retry loop in one place and a
dead collector in the other:

- MarmotInnerEvent.equals compares `logger`, an @Ignore'd `Logger.withTag(TAG)`
  initialised per instance. Kermit's `withTag` returns `Logger(this.config, tag)`
  -- a fresh object -- and neither Logger nor BaseLogger overrides equals, so
  two reads of one row are never equal. distinctUntilChanged suppressed nothing
  here, and the notary spent the session retrying the stopper and never looking
  past it. Correct behaviour by accident, resting on a field that is not part of
  the row.
- GiftWrapPayload.equals is an honest value comparison with no logger in it. The
  refused payload's re-emission compared equal and was dropped, so after one
  refusal nothing on that queue was collected again for the life of the session,
  whatever was queued afterwards.

**Why it reads as unsealed.** ChatMessageListViewModel picks its status icon off
three relations, in order: a broadcast receipt, a broadcast request, a
NostrEvent. A queued marmot message has none of them until the notary turns it
into a kind:445, so it falls to the last branch -- KeyOff, "Unsealed message
status". The icon is accurate. The message is exactly as unsealed as it looks,
and will stay that way.

**The gift wrap queue has it too, and a Welcome rides it.** MIP-02 addresses
kind:444 to a joiner who holds no group state and cannot read a kind:445, so
MarmotOutboundDao.deliveryWelcome queues one as a GiftWrapPayload deliberately
-- marmot traffic on the NIP-17 path. That queue had the same single-row shape
and no ORDER BY at all, so which row was "the head" was whatever SQLite
returned first. Two known refusals leave a payload there with `giftWrapSealId`
still null: sealGiftWrapPayload refuses outright to seal a non-Welcome payload
belonging to an MLS room (65e4a3a, and the comment there already named the
blockage this causes), and a Welcome whose joiner published no key package
matches no participant and produces no wraps at all.

**The fix.** Both queries return the backlog instead of its head, ordered
`createdAt ASC, id ASC` -- the same ordering BroadcastNostrEventRequestDao
settled on, and for the same reason: createdAt is persisted at second
resolution, a burst of sends shares one, and an order that is only ever "some
row with this timestamp" lets two passes disagree about what comes next.

NotaryViewModel walks the list and keeps guardNotarization per row, so a
failure costs only itself. It walks it serially and in order on purpose: each
send ratchets its room's MLS state forward and writes it back, and
encryptAndSendMarmotInnerEvent re-reads that state per row, so two sends for
one room in parallel would encrypt from the same generation and the group could
read only one of them.

The two `?.let`s become two throws, which the per-row guard logs. A failed row
writes nothing -- the DAO is one transaction -- so it stays queued and is tried
again on the next pass. That is wanted: a room whose state has not caught up
yet deserves the retry, and a room that never will is at least no longer
standing in front of anybody. The retry is bounded by the fact that it is
Room's invalidation driving it: a pass in which every remaining row fails
writes nothing, invalidates nothing and emits nothing further.

No schema change. The queue's shape was in the query and the collector, not in
the table.

**Tests.** MarmotOutboundQueueJvmTest, eight of them, Room-backed against a real
MlsGroup -- the DAO seam MarmotOutboundDaoJvmTest opened, which 0211764 could
not use and said so. Two rooms stand side by side, one holding real MLS state
and one holding none, and the unsendable row is queued first on purpose because
under the old queue it was the only row the notary ever saw.

The one that matters is "a message that cannot be sent no longer holds up the
ones behind it": the stopper fails, exactly once, and the message behind it in
another room still comes out with a group event and one pending
BroadcastNostrEventRequest per relay -- pending because, per 0211764, that is
the only status the broadcaster looks at and the only thing that actually puts
a kind:445 on a relay. The rest pin the supporting facts: the backlog arrives
whole and oldest first, rows sharing a second come back in the same order
twice, a room with no MLS state and a room that does not exist are each refused
rather than ignored, a refused send writes neither a group event nor a
broadcast request, and two messages for one room each ratchet the group forward
and both leave the queue.

**Not covered, deliberately.** The notary's third queue, unsigned Nostr events,
still has this shape, and UnsignedNostrEvent.equals is a value comparison, so it
is the frozen-collector variant rather than the retrying one. NostrDao.kt's
comment on commitPublishedNostrEvent records that it has already bitten once --
an indexing throw rolled back `signedAt` and "every later event (including the
MLS key package, which is enqueued last) would never be signed at all" -- fixed
point-wise by moving indexing out of the transaction, leaving the queue shape
untouched. It carries account traffic rather than group messages and
NavigationViewModel gates the user on it, so it is its own change.

Nothing here surfaces *why* a message is stuck. A row that can never be sent is
still never sent; it is only no longer contagious. Telling the sender that
would want a persisted attempt count and a place in the UI to put it, which is
also its own change.

Verified: :composeApp:compileDebugKotlinAndroid succeeds; 511 tests pass, 503
before these eight. That a stuck room no longer silences a healthy one is
asserted against a real group in a real database, not inferred -- but that a
second participant now receives the messages that were backing up is inference
from the code, since it wants two devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:56:33 +02:00
..