feat: let a member with none of the group's work ask for it

Phase 5 of docs/member-archive.md, and the half that makes the whole thing
reliable. A device opening a room it holds no signed work for asks the group;
any member holding the work answers, addressed to whoever asked.

**A request cannot lose the race a push loses.** Pushing an archive at an invitee
is an application message in the epoch the add created, and one that overtakes
the Welcome is dropped rather than deferred -- silently, while the inviter sees a
success. That is marmot-membership.md's failure mode arriving in a new costume.
Sending a request cannot lose it, because being able to send one is the proof it
was won: a device that can put an application message into the room has processed
its Welcome and is at the group's epoch.

It also covers three things no invite-time push reaches, and they are answered by
one rule because they are indistinguishable from inside the database: a member
added after the work, a reinstall whose invite is long past, and a second device
that was never invited at all. Hence the crude condition -- no dialects and no
artifacts -- rather than anything that tries to tell them apart.

**Anyone may answer and nobody is elected to.** A duplicate answer costs
bandwidth and nothing else: pages are idempotent and every member who is not the
named recipient ignores them. So the stand-down that would avoid the waste is an
optimisation to add later rather than a correctness gap to close now. A member
with nothing signed answers nothing at all, which is the honest reply from one
still catching up themselves, and beats an empty archive that looks like an
answer.

**Queued with no chat line**, the way a signing message travels. Broadcast does
not depend on one -- `encryptAndSendMarmotInnerEvent` inserts its
`BroadcastNostrEventRequest` unconditionally, which is what let the FROST rounds
travel with no transcript -- and an archive that filed a line per page would put
a row of envelopes in the room's history. One line per archive is the right
number and it is not writable from here, since the pages are indistinguishable
from each other at this point.

**Schema 11 -> 12**: `ChatRoom.archiveRequestedAt`, nullable, so Room generates
the migration. It stops a device asking again on every launch while an answer is
in flight. Rooms written before it read back null, meaning "never asked", which
is true of all of them and harmless.

Cleared as soon as an archive applies anything -- not when a sender's page count
claims the archive was complete. A page count is the sender's word about the
transfer rather than about the group's record, so a member who left work out must
not get the last word on whether to ask again. A partial answer is followed by
another request rather than by silence.

The trigger is opening the room, through `ChatRepository` rather than from the
view model into the database. Cheap to call every time: it stops at a room that
already holds work and at one still waiting. A failure is not reported, because
nothing acknowledges a request and the next open asks again.

Eight tests: a device with nothing asks and does not ask twice, a device with
work does not ask, answering queues pages addressed to the asker with every
archivable kind in them and no chat line, a member with nothing signed answers
nothing, a member does not answer themselves, an applied archive clears the stamp
so a partial answer can be followed up, and the whole round trip -- ask, answer,
apply -- leaves the joiner holding the sender's rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 14:25:16 +02:00
parent ed4410b972
commit 873203e4b4
9 changed files with 5831 additions and 2 deletions

View File

@@ -174,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 11,
version = 12,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -228,7 +228,14 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// it read back null and are read by the clock, the way they always were:
// a room that could only propose one thing at a time cannot have written
// two sessions' lines into the same stretch of transcript.
AutoMigration(from = 10, to = 11)
AutoMigration(from = 10, to = 11),
// v12 adds the nullable ChatRoom.archiveRequestedAt, which stops a device
// with no signed work in a room asking the group for its history on every
// launch while an answer is in flight. Rooms written before it read back
// null, meaning "never asked" -- true of all of them, and harmless: the
// request only goes out for a room that holds no signed work, and a room
// that holds some will not ask.
AutoMigration(from = 11, to = 12)
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -27,6 +27,7 @@ import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.archive.ArchiveRequestEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
@@ -637,6 +638,26 @@ data class ChatMessage(
return null
}
// Somebody in the room holds none of its signed work and is
// asking. Any member may answer and none is elected to: a
// duplicate answer costs bandwidth and nothing else, since
// pages are idempotent and everyone but the recipient ignores
// them. A member with nothing signed answers nothing, which
// is the honest reply from one still catching up themselves.
if (event.kind == ArchiveRequestEvent.KIND) {
ArchiveManager.answer(
database = database,
chatRoomId = groupEventResult.groupId,
userPublicKey = activeKeyPair.pubKey.toHex(),
// The MLS frame is the authenticated source. For these
// kinds MIP-03 already forces the two to agree, so the
// fallback is for a result that carries no leaf index.
requester = senderIdentity ?: event.pubKey,
)
return null
}
// A submission whose payload will not parse, or which carries
// another submission, is kept but not applied: there is nothing
// here we can turn into a row.

View File

@@ -79,6 +79,21 @@ data class ChatRoom(
val leftGroupAt: Instant? = null,
/**
* When this device last asked the group for its signed history, or null if
* it never has or the answer has since arrived.
*
* A device with no signed work in a room asks for an archive -- see
* `ArchiveManager.requestIfEmpty` and docs/member-archive.md. This is the
* only thing stopping it asking again on every launch while an answer is in
* flight, and it is cleared as soon as an archive applies anything, so a
* partial answer is followed by another request rather than by silence.
*
* Not a claim that anybody replied. Nothing acknowledges a request, and the
* room having work in it is the only evidence that ever arrives.
*/
val archiveRequestedAt: Instant? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.database.repository
import press.mantra.compose.database.GENESIS_AT
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.managers.ArchiveManager
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GiftWrapPayload
@@ -51,6 +52,15 @@ class DatabaseChatRepository(
return database.chatRoomDao().findChatRoomById(id)
}
override suspend fun requestGroupHistoryIfMissing(
chatRoomId: String,
userPublicKey: HexKey
): Boolean = ArchiveManager.requestIfEmpty(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
)
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant> {
return database.participantDao().findParticipantByPublicKey(publicKey)
}

View File

@@ -3,6 +3,8 @@ package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -10,7 +12,10 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.extensions.toHex
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.archive.ArchiveRequestEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
@@ -100,6 +105,146 @@ object ArchiveManager {
}
}
// ---- Asking, and answering -------------------------------------------
/**
* Ask the group for its signed history, if this device holds none of it.
*
* Called on entering a room. The condition is deliberately crude -- no
* dialects and no artifacts -- because the three cases it has to catch look
* identical from inside the database and should not be told apart:
*
* - a member added after the work was done, whose invite may be recent;
* - a reinstall, whose invite is long past;
* - a second device, which was never invited at all.
*
* Returns whether a request went out.
*
* ### Why a device asks rather than being sent one
*
* A push from the inviter is an application message in the epoch the add
* created, and one that overtakes the Welcome is dropped and not deferred --
* silently, while the inviter sees a success. See docs/marmot-membership.md.
* Sending a request cannot lose that race, because being able to send it is
* the proof the race was won: a device that can put an application message
* into the room has processed its Welcome.
*/
suspend fun requestIfEmpty(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
): Boolean {
val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom ?: return false
if (chatRoom.archiveRequestedAt != null) return false
val holdsWork = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).isNotEmpty() ||
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).isNotEmpty()
if (holdsWork) return false
queue(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
kind = ArchiveRequestEvent.KIND,
tags = ArchiveRequestEvent.build().tags,
content = "",
)
database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = Clock.System.now()))
logger.i("Asked $chatRoomId for its signed history")
return true
}
/**
* Answer [requester]'s request with everything this device can prove.
*
* Any member may answer and none is elected to. A duplicate answer costs
* bandwidth and nothing else -- pages are idempotent and every member who is
* not the recipient ignores them -- so this is waste rather than damage, and
* the stand-down that would avoid it is an optimisation to add on top rather
* than a correctness gap to close first.
*
* A device with nothing signed answers nothing. Silence is the honest reply
* from a member who is themselves still catching up, and an empty archive
* would look like an answer.
*/
suspend fun answer(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
requester: HexKey,
): Int {
if (requester.equals(userPublicKey, ignoreCase = true)) return 0
val pages = assemble(database, chatRoomId, requester)
if (pages.isEmpty()) {
logger.i("Cannot answer ${requester.take(8)}'s request for $chatRoomId: nothing signed here")
return 0
}
pages.forEach { page ->
queue(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
kind = page.kind,
tags = page.tags,
content = page.content,
createdAt = Instant.fromEpochSeconds(page.createdAt),
)
}
logger.i("Answering ${requester.take(8)} with ${pages.size} archive page(s) for $chatRoomId")
return pages.size
}
/**
* Put one event on the room's outbound queue.
*
* The same shape `FrostSigningManager.broadcast` uses: a `MarmotInnerEvent`
* with no group event yet, which the notary picks up and encrypts into a
* kind:445 for the room. The id is the rumor id the outbound pipeline
* recomputes from these fields when it assembles the event to encrypt.
*
* **No `ChatMessage`.** Broadcast does not depend on one -- it is
* unconditional in `encryptAndSendMarmotInnerEvent`, which is what let the
* signing sessions travel with no transcript line -- and an archive that
* filed one per page would put a row of envelopes in the room's history. One
* line per archive is right, and it is not writable from here, because the
* pages of an archive are indistinguishable from each other at this point.
*/
private suspend fun queue(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
kind: Int,
tags: TagArray,
content: String,
createdAt: Instant = Clock.System.now(),
) {
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
createdAt = createdAt.epochSeconds,
tags = tags,
content = content,
kind = kind
),
publicKey = userPublicKey,
kind = kind,
createdAt = createdAt,
tags = tags,
content = content,
chatRoomId = chatRoomId,
)
)
}
// ---- Receiving ------------------------------------------------------
/**
@@ -223,6 +368,19 @@ object ArchiveManager {
"${pass.applied} applied, ${pass.skipped} skipped, ${pass.failed} left"
)
// An answer arrived, so the room may ask again if it turns out to be a
// partial one. Cleared on anything applied rather than on the archive
// reporting itself complete: a page count is the sender's claim about the
// transfer, not about the group's record, and a member who left work out
// would otherwise have the last word.
if (pass.applied > 0) {
database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.let { chatRoom ->
if (chatRoom.archiveRequestedAt != null) {
database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = null))
}
}
}
return pass
}

View File

@@ -22,6 +22,16 @@ interface ChatRepository {
suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom?
/**
* Ask the group for its signed history if this device holds none of it.
*
* True when a request went out. Safe to call on every open: it is a no-op
* for a room that already holds work and for one still waiting on an answer.
* See docs/member-archive.md for why the joiner asks rather than the inviter
* pushing.
*/
suspend fun requestGroupHistoryIfMissing(chatRoomId: String, userPublicKey: HexKey): Boolean
suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant>
suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage>
@@ -132,6 +142,11 @@ interface ChatRepository {
companion object {
val NO_OP_CHAT_REPOSITORY = object: ChatRepository {
override suspend fun requestGroupHistoryIfMissing(
chatRoomId: String,
userPublicKey: HexKey
): Boolean = false
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
return flow { }
}

View File

@@ -155,6 +155,31 @@ class ChatMessageListViewModel(
logger.d("init")
scheduleSynchronization()
observeChatRoomFeed()
askForGroupHistory()
}
/**
* Ask the group for its signed record, if this device holds none of it.
*
* Opening the room is the trigger because it is the first moment this device
* is demonstrably in the group's current epoch -- see
* docs/member-archive.md. A member added after the work was done has no way
* to see any of it otherwise: MLS gives them no history, and a group-signed
* event never travels, so every device derives it or does without.
*
* Cheap to call every time. It stops at a room that already holds work and
* at one still waiting on an answer, and a failure is not worth telling
* anybody about: nothing acknowledges a request, so the next open asks again.
*/
private fun askForGroupHistory() {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
chatRepository.requestGroupHistoryIfMissing(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
)
}.onFailure { logger.w(throwable = it) { "Could not ask for this group's history" } }
}
}
fun observeChatRoomFeed() {

View File

@@ -16,6 +16,7 @@ import fr.acinq.bitcoin.crypto.frost.Session
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -30,6 +31,7 @@ import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.archive.ArchiveRequestEvent
import press.mantra.compose.nostr.archive.tags.ArchiveIdTag
import press.mantra.compose.nostr.archive.tags.ArchivePageTag
import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag
@@ -590,4 +592,127 @@ class ArchiveApplyJvmTest {
assertEquals(afterFirst, rowCounts(receiver))
assertEquals(0, receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size)
}
// ---- Asking for one --------------------------------------------------
private suspend fun queued(db: MantraDatabase, kind: Int) = db.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId, listOf(kind))
.filter { it.marmotGroupEventId == null }
@Test
fun `a device holding none of the room's work asks for it`() = runBlocking {
seedRoom(receiver, newMember)
assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember))
// Queued with no chat line, the way a signing message travels. Broadcast
// does not depend on one, and a row of envelopes in the transcript is not
// what an archive should leave behind.
assertEquals(1, queued(receiver, ArchiveRequestEvent.KIND).size)
assertEquals(0, receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size)
}
@Test
fun `a device does not ask twice while an answer is in flight`() = runBlocking {
seedRoom(receiver, newMember)
assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember))
assertFalse(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember))
assertEquals(1, queued(receiver, ArchiveRequestEvent.KIND).size)
}
@Test
fun `a device holding the work does not ask for it`() = runBlocking {
seedSenderWork()
assertFalse(ArchiveManager.requestIfEmpty(sender, chatRoomId, oldMember))
assertEquals(0, queued(sender, ArchiveRequestEvent.KIND).size)
}
@Test
fun `an answered request lets the room ask again`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)
assertNotNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt)
senderArchive().forEach { deliver(it) }
// Cleared on anything applied rather than on the sender claiming the
// archive was complete. A page count is the sender's word about the
// transfer, not about the group's record, so a member who left work out
// must not get the last word on whether to ask again.
assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt)
}
@Test
fun `answering a request queues the archive for whoever asked`() = runBlocking {
seedSenderWork()
val pages = ArchiveManager.answer(sender, chatRoomId, oldMember, requester = newMember)
assertEquals(1, pages)
val queuedPage = queued(sender, ArchiveEvent.KIND).single()
assertEquals(oldMember, queuedPage.publicKey, "the page is the answering member's own rumor")
val page = ArchiveEvent(
id = queuedPage.id,
pubKey = queuedPage.publicKey,
createdAt = queuedPage.createdAt.epochSeconds,
tags = queuedPage.tags,
content = queuedPage.content,
sig = "",
)
assertEquals(newMember, page.recipient())
assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, page.payloads()?.map { it.kind }?.toSet())
// And no chat line for it, for the same reason the request has none.
assertEquals(0, sender.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size)
}
@Test
fun `a member with nothing signed answers nothing rather than an empty archive`() = runBlocking {
seedRoom(receiver, newMember)
assertEquals(0, ArchiveManager.answer(receiver, chatRoomId, newMember, requester = oldMember))
assertEquals(0, queued(receiver, ArchiveEvent.KIND).size)
}
@Test
fun `a member does not answer their own request`() = runBlocking {
seedSenderWork()
assertEquals(0, ArchiveManager.answer(sender, chatRoomId, oldMember, requester = oldMember))
assertEquals(0, queued(sender, ArchiveEvent.KIND).size)
}
@Test
fun `asking and answering completes the round trip`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
// The joiner asks.
assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember))
// A member holding the work answers it, addressed to whoever asked.
ArchiveManager.answer(sender, chatRoomId, oldMember, requester = newMember)
// The pages reach the joiner, who applies them.
queued(sender, ArchiveEvent.KIND).forEach { queuedPage ->
deliver(
EventTemplate(
createdAt = queuedPage.createdAt.epochSeconds,
kind = queuedPage.kind,
tags = queuedPage.tags,
content = queuedPage.content,
)
)
}
assertEquals(rowCounts(sender), rowCounts(receiver))
assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt)
}
}