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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 14:29:11 +02:00
parent 873203e4b4
commit 6d8866c2b0
5 changed files with 159 additions and 28 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
)
}
}