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

View File

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