Files
mantra-kmp/composeApp
Kgothatso Ngako 49c012bf8b fix: a member is not shown the messages sent before they were in the room
A joiner gets the MLS key schedule from their own epoch forward and nothing
before it. The relay does not know that and hands them the whole room: negentropy
syncs down every kind:445 the group ever published, `indexMarmotGroupEvent` read
each one against the group, the outer layer refused, and every refusal wrote an
`undecryptableOuterLayer` line. So the room a member had just been invited to
opened on a screenful of "Undecryptable Message" above the conversation -- one
per message the group had sent before they arrived, none of them ever readable,
and the count only grows with how long the group had been talking.

**The epoch of a kind:445 is inside the layer that will not decrypt**, so an
event this device cannot read cannot be asked what epoch it is from. "From before
we joined", "from an epoch we have not caught up to" and "from an epoch that fell
out of the retention window" are indistinguishable from the outside, and only the
first is permanent. What separates it is not the ciphertext but the clock: it was
published before the group made the epoch we joined at.

**`ChatRoom.joinedGroupAt` is that moment, written down.** The Welcome's
`created_at`, which the inviter stamps as it mints the Welcome out of the Add
commit that made us a member -- so it is the group's own account of when our
epoch began, not this device's account of when it heard about it. A group this
device created sets it to the room's creation; it was a member from epoch 0 and
there is nothing behind it to hold back.

Stored rather than read off `createdAt`, which today holds the same value in both
paths. `createdAt` is row bookkeeping and this decides which of a group's
messages a member is allowed to see at all; the two being equal is a coincidence
of the current code, and hanging the second off the first makes a future change
to when a room row is written into a change in what gets discarded. `memberSince`
is `joinedGroupAt ?: createdAt`, so a room joined before the column existed gets
the fix too -- and gets it from the value every path that sets the column would
have written anyway.

**`predatesMembership` draws the line strictly before**, and that is a judgement
rather than a fact. Nostr stamps `created_at` in whole seconds, so the second the
Welcome was minted holds both the commit that added us -- the last act of the
epoch before ours, unreadable by construction -- and any message another member
sent the instant they applied it. Only one of the two can be had. An unreadable
event kept costs one refused decrypt; a readable event discarded is a message the
member never sees. So the second is kept, and a room may still show a single
placeholder for the commit that added its newest member.

**Two places gate on it.** `indexMarmotGroupEvent` returns before touching the
MLS group, so nothing is decrypted, no `MarmotGroupEvent` row is filed for
ciphertext whose key this device never had, and no line is written.
`reindexMarmotGroupEvents` partitions them out of the sweep entirely: a replay
can say in advance that no pass will ever read them, so replaying them only
spends a refused decrypt per sweep and reports every one as a failure on a room
where nothing is wrong. `MarmotReindexSweep` is untouched apart from carrying the
new count -- it decides how many times to go round, not what is worth going round
for.

**Schema v15, and the migration is the half that fixes devices already showing
the bug.** Nothing rewrites a chat line that is already in the transcript, so
fixing the write path alone would leave every member who joined a busy room
opening it on the same run of placeholders forever. `MIGRATION_14_15` adds the
column and deletes the lines: only the two types in `UNRESOLVED_MARMOT_TYPES`,
and only where the group event behind them predates the room. Those lines say
nothing by design -- they stand in for an event that was never read -- so
removing one loses nothing, while every other line is the final word on its group
event. The group events themselves stay; this is about what the room shows.

The column is left null rather than backfilled from `createdAt`. Null already
means "ask `createdAt`", and copying the value would turn a fallback into a claim
this migration is in no position to make. It is manual rather than an
`AutoMigration` only because of the delete: `ALTER TABLE ... ADD COLUMN` appends,
which is where Room's own generated migration for a nullable addition puts one,
and Room compares a table's columns by name rather than by position.

**The reindex report stopped being true**, so it carries the number now. With the
backlog held back, `unresolved` falls to zero and the screen said "Nothing to
reindex - 30 event(s) all read" about a room where 27 of them were never this
device's to read. `MarmotReindexReport.predatingMembership` is reported alongside
`stored`, and the detail screen names it: "3 event(s) all read - 27 from before
you joined". A member invited into an old room is the ordinary case, not an
anomaly to bury in a total.

Seventeen tests. `ChatRoomMembershipWindowTest` holds the boundary, including the
same-second case and both directions of the `createdAt` fallback.
`JoinedGroupAtMigrationJvmTest` runs the migration's own SQL against v14's three
tables and covers what it must not take as carefully as what it must: a
placeholder for an event from *after* the join is left to be recovered, a message
that was read is left alone however old it is, a line with no group event behind
it is out of reach of the rule, and two rooms joined at different times are each
measured against their own join. `MarmotPreJoinIndexingJvmTest` drives the DAO
against a room with no MLS state, which is what separates "left alone because it
predates the join" from "tried and failed".

520 jvm tests and 302 android unit tests pass.

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