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
}