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>
This commit is contained in:
Kgothatso Ngako
2026-09-06 18:18:16 +02:00
parent 19d57ef3a5
commit 49c012bf8b
14 changed files with 6402 additions and 27 deletions

View File

@@ -0,0 +1,103 @@
package press.mantra.compose.database.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Which of a group's kind:445 events are this device's to read at all.
*
* MLS hands a joiner the key schedule from their own epoch forward and nothing
* before it, but the relay hands them the whole room. Everything the group
* published earlier still syncs down and is still permanently unreadable, so a
* member invited into a busy room opened it on a run of "Undecryptable Message"
* above the conversation they were invited to.
*
* The epoch an event was encrypted under is inside the layer that will not
* decrypt, so the only thing left to ask is when it was published. That makes
* the boundary a judgement call rather than a fact, and the direction it errs in
* is what these pin down: an unreadable event kept costs one refused decrypt, a
* readable event discarded is a message the member never sees.
*/
class ChatRoomMembershipWindowTest {
private val roomId = "a".repeat(64)
private val user = "b".repeat(64)
private fun room(
joinedGroupAt: Instant?,
createdAt: Instant = Instant.fromEpochSeconds(5_000),
) = ChatRoom(
id = roomId,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
joinedGroupAt = joinedGroupAt,
createdAt = createdAt,
)
@Test
fun `a message from before the welcome is not this devices to read`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(1_999)))
}
@Test
fun `a message from after the welcome is`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_001)))
}
/**
* The boundary, and the reason it is drawn strictly.
*
* 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 those two can be had, and a
* message is worth more than a spared decrypt.
*/
@Test
fun `a message from the very second of the welcome is kept`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_000)))
}
/**
* A room joined before the column existed. `createdAt` is when this device
* wrote the row down, which for a joiner is the Welcome it wrote it from --
* the same answer every path that sets `joinedGroupAt` would have written.
*/
@Test
fun `a room with no recorded join falls back to when it was written down`() {
val chatRoom = room(joinedGroupAt = null, createdAt = Instant.fromEpochSeconds(5_000))
assertEquals(Instant.fromEpochSeconds(5_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(4_999)))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(5_000)))
}
/**
* A device that joined a room it had already heard of -- its own invite
* gift wrap arrived out of order, say, and the row was written before the
* Welcome was processed. The recorded join is the group's own account of
* when our epoch began and beats this device's account of when it started
* keeping notes.
*/
@Test
fun `a recorded join wins over when the row was written`() {
val chatRoom = room(
joinedGroupAt = Instant.fromEpochSeconds(9_000),
createdAt = Instant.fromEpochSeconds(5_000),
)
assertEquals(Instant.fromEpochSeconds(9_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(6_000)))
}
}