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