From 6d8866c2b07e89e418bbeebb03832e8ac0fd8228 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 14:29:11 +0200 Subject: [PATCH] feat: offer a new member the group's work alongside their welcome Phase 6 of docs/member-archive.md, and deliberately the phase after the one that makes it unnecessary. `deliveryWelcome` now queues an archive for the member being invited, so in the ordinary case they have the group's signed work before they think to ask for it. **This is a latency optimisation, not the mechanism.** A page queued behind the Welcome is not delivered after it: they are different transports -- a relay-borne gift wrap and a kind:445 -- with no ordering between them, and a page that overtakes the Welcome is from an epoch ahead of the invitee's, so `MarmotInboundManager` drops it outright rather than deferring it. Nothing retries and the inviter sees a success. That is the failure in docs/marmot-membership.md wearing new clothes, and the only thing that closes it is the invitee asking once they are demonstrably in the group, which Phase 5 already does on their first open of the room. So nothing here reports failure to the inviter. A push that does not land is the ordinary case the pull exists for, and it sits inside `deliveryWelcome`'s own catch alongside the Welcome it rides behind. A room with nothing signed queues nothing and still invites. **One call, two occasions.** `ArchiveManager.answer` becomes `sendTo`: answering a request and pushing behind a Welcome are the same operation and differ only in who decided, so it is named for what it does rather than for either occasion. Also corrects the plan. Phase 6 claimed the room had to be re-read between the invite and the assembly, for the same reason sequential invites re-read it. It does not -- that rule is about the MLS snapshot a commit is built on, and this runs downstream of the commit over `Mantra*` rows, which no commit touches. Two tests against a real `deliveryWelcome`: inviting into a room with signed work queues exactly one archive page addressed to the invitee, and inviting into a room with none queues no page while still writing the Welcome's gift wrap. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/MarmotOutboundDao.kt | 28 ++++++ .../compose/database/model/ChatMessage.kt | 4 +- .../mantra/compose/managers/ArchiveManager.kt | 30 +++--- .../compose/managers/ArchiveApplyJvmTest.kt | 98 ++++++++++++++++++- docs/member-archive.md | 27 +++-- 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index e6c5049f..2675d539 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Transaction import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.managers.ArchiveManager import press.mantra.compose.database.model.BroadcastNostrEventRequest import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation @@ -521,6 +522,33 @@ abstract class MarmotOutboundDao( ) ) } + + // The group's signed work, offered to the member being invited. + // Nothing else will ever show it to them: MLS gives a joiner no + // history, and a group-signed event never travels -- every device + // derives it from a session it took part in, or does without. See + // docs/member-archive.md. + // + // **Queued behind the Welcome is not delivered after it.** These + // are different transports -- a relay-borne gift wrap and a + // kind:445 -- with no ordering between them, and a page that + // arrives before the invitee has processed their Welcome is from + // an epoch ahead of theirs and is dropped outright rather than + // deferred. So this is a latency optimisation and not the + // mechanism: what recovers it is the invitee asking for + // themselves once they are in, which `ArchiveManager.requestIfEmpty` + // does on their first open of the room. + // + // A push that does not land is therefore the ordinary case rather + // than an error, and nothing here reports one to the inviter. It + // is inside this function's catch for that reason, alongside the + // Welcome it rides behind. + ArchiveManager.sendTo( + database = database, + chatRoomId = nostrGroupId, + userPublicKey = userPublicKey, + recipient = marmotKeyPackage.publicKey, + ) } } catch (e: Throwable) { logger.e("Failed to deliver welcome:", e) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index c3febd6e..d98e1dab 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -645,14 +645,14 @@ data class ChatMessage( // 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( + ArchiveManager.sendTo( 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, + recipient = senderIdentity ?: event.pubKey, ) return null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt index 3303de16..0be55765 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt @@ -159,29 +159,33 @@ object ArchiveManager { } /** - * Answer [requester]'s request with everything this device can prove. + * Send [recipient] everything this device can prove about [chatRoomId]. * - * 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. + * Both ways an archive goes out are this one call: answering a request, and + * the push behind a Welcome. Named for what it does rather than for either + * occasion, because the two differ only in who decided. * - * A device with nothing signed answers nothing. Silence is the honest reply + * Any member may send one and none is elected to. A duplicate costs bandwidth + * and nothing else -- pages are idempotent and every member who is not the + * recipient ignores them -- so it 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 sends 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( + suspend fun sendTo( database: MantraDatabase, chatRoomId: String, userPublicKey: HexKey, - requester: HexKey, + recipient: HexKey, ): Int { - if (requester.equals(userPublicKey, ignoreCase = true)) return 0 + if (recipient.equals(userPublicKey, ignoreCase = true)) return 0 - val pages = assemble(database, chatRoomId, requester) + val pages = assemble(database, chatRoomId, recipient) if (pages.isEmpty()) { - logger.i("Cannot answer ${requester.take(8)}'s request for $chatRoomId: nothing signed here") + logger.i("Nothing signed here to send ${recipient.take(8)} for $chatRoomId") return 0 } @@ -197,7 +201,7 @@ object ArchiveManager { ) } - logger.i("Answering ${requester.take(8)} with ${pages.size} archive page(s) for $chatRoomId") + logger.i("Sending ${recipient.take(8)} ${pages.size} archive page(s) for $chatRoomId") return pages.size } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt index 44aaf388..281255db 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Instant import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase @@ -27,6 +28,7 @@ import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.MarmotInnerEvent +import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.toHex @@ -35,6 +37,7 @@ 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 +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -651,7 +654,7 @@ class ArchiveApplyJvmTest { fun `answering a request queues the archive for whoever asked`() = runBlocking { seedSenderWork() - val pages = ArchiveManager.answer(sender, chatRoomId, oldMember, requester = newMember) + val pages = ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) assertEquals(1, pages) @@ -677,7 +680,7 @@ class ArchiveApplyJvmTest { 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, ArchiveManager.sendTo(receiver, chatRoomId, newMember, recipient = oldMember)) assertEquals(0, queued(receiver, ArchiveEvent.KIND).size) } @@ -685,7 +688,7 @@ class ArchiveApplyJvmTest { fun `a member does not answer their own request`() = runBlocking { seedSenderWork() - assertEquals(0, ArchiveManager.answer(sender, chatRoomId, oldMember, requester = oldMember)) + assertEquals(0, ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = oldMember)) assertEquals(0, queued(sender, ArchiveEvent.KIND).size) } @@ -698,7 +701,7 @@ class ArchiveApplyJvmTest { assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)) // A member holding the work answers it, addressed to whoever asked. - ArchiveManager.answer(sender, chatRoomId, oldMember, requester = newMember) + ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) // The pages reach the joiner, who applies them. queued(sender, ArchiveEvent.KIND).forEach { queuedPage -> @@ -715,4 +718,91 @@ class ArchiveApplyJvmTest { assertEquals(rowCounts(sender), rowCounts(receiver)) assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt) } + + // ---- The push behind a Welcome --------------------------------------- + + /** + * The invitee's key package, which is how `deliveryWelcome` learns who it is + * addressing. The bytes are never decoded on this path. + */ + private suspend fun seedInviteeKeyPackage(): String { + val keyPackageId = "1".repeat(64) + val nostrEventId = "2".repeat(64) + + sender.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = newMember, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + sender.profileDao().upsert( + Profile(publicKey = newMember, userName = "invitee", nostrEventId = nostrEventId) + ) + sender.marmotKeyPackageDao().upsert( + MarmotKeyPackage( + id = keyPackageId, + publicKey = newMember, + tlsEncodedMarmotKeyPackage = ByteArray(0), + ) + ) + + return keyPackageId + } + + @Test + fun `inviting a member offers them the group's work alongside the welcome`() = runBlocking { + seedSenderWork() + + sender.marmotOutboundDao().deliveryWelcome( + nostrGroupId = chatRoomId, + userPublicKey = oldMember, + welcomeBytes = ByteArray(8), + peerKeyPackageEventId = seedInviteeKeyPackage(), + relays = listOf("wss://relay.example.com"), + createdAt = Clock.System.now(), + ) + + val page = queued(sender, ArchiveEvent.KIND).single() + + assertEquals( + newMember, + ArchiveEvent( + id = page.id, + pubKey = page.publicKey, + createdAt = page.createdAt.epochSeconds, + tags = page.tags, + content = page.content, + sig = "", + ).recipient() + ) + } + + @Test + fun `inviting into a room with nothing signed offers nothing, and still invites`() = runBlocking { + seedRoom(sender, oldMember) + + sender.marmotOutboundDao().deliveryWelcome( + nostrGroupId = chatRoomId, + userPublicKey = oldMember, + welcomeBytes = ByteArray(8), + peerKeyPackageEventId = seedInviteeKeyPackage(), + relays = listOf("wss://relay.example.com"), + createdAt = Clock.System.now(), + ) + + assertEquals(0, queued(sender, ArchiveEvent.KIND).size) + // The Welcome itself still went out. A push that has nothing to say is + // not a failed invite, and nothing about the archive may report one: + // the invitee asks for themselves on their first open regardless. + assertEquals( + 1, + sender.giftWrapPayloadDao() + .getByChatRoomAndKinds(chatRoomId, listOf(WelcomeEvent.KIND)) + .size + ) + } } diff --git a/docs/member-archive.md b/docs/member-archive.md index 2534b833..cf09bddb 100644 --- a/docs/member-archive.md +++ b/docs/member-archive.md @@ -502,18 +502,27 @@ written as one. `DatabaseNostrRepository`. Assemble an archive for the invitee there and queue its pages behind the Welcome. -Two things to be honest about at that call site, in a comment: +One thing to be honest about at that call site, in a comment: **queued behind the +Welcome is not delivered after it.** They are different transports -- a +relay-borne gift wrap and a kind:445 -- and a page that arrives before the +invitee has processed their Welcome is from an epoch ahead of theirs, so it is +dropped outright rather than deferred. The request is what recovers that, and +this push is worth having only because it usually wins. -- **Queued behind the Welcome is not delivered after it.** They are different - transports -- a relay-borne gift wrap and a kind:445 -- and pages that arrive - first are dropped for good. The request is what recovers that, and this push is - worth having only because it usually wins. -- **The room must be re-read between the invite and the assembly**, for the same - reason sequential invites re-read it: a snapshot taken before the commit - describes an epoch the group has left. +The first draft of this section also said the room must be re-read between the +invite and the assembly, for the same reason sequential invites re-read it. It +does not: that rule is about the MLS snapshot a commit is built on, and +`deliveryWelcome` is downstream of the commit and reads `Mantra*` rows, which no +commit touches. Nothing here is allowed to report failure to the inviter. A push that does not -land is not an error; it is the ordinary case the pull exists for. +land is not an error; it is the ordinary case the pull exists for. It sits inside +`deliveryWelcome`'s own catch for that reason. + +**One call, two occasions.** Answering a request and pushing behind a Welcome are +the same operation and differ only in who decided, so they are one function named +for what it does -- `ArchiveManager.sendTo` -- rather than two named for their +occasions. ---