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