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:
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user