mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
feat(cordn): carry the conversation through a device handoff
A migration moves every group's MLS state, cursor, draft, read position and KeyPackages to the new phone — and left the conversations behind. Every other piece has a second source: MLS state re-derives from the coordinator's stream, a KeyPackage can be republished. A cordn message has none. It is readable exactly once, at ingest, because both seal keys are epoch-derived and the cursor this very document carries has already advanced past everything behind it. A device seeded without the messages arrives holding every group and no conversation, permanently. Carried as `amethystMessages` on the group document, beside the other additive `amethyst*` fields, so an older reader ignores it rather than failing. Bounded per group by a byte budget, newest first. These blobs go to hosts whose limits we do not know, and a handoff that fails outright because one group is chatty is a worse outcome than one that carries a deep but bounded history. Budgeted in bytes rather than messages because a single long message can cost as much as a hundred short ones. Written before the cursor on import, for the same reason the live path writes them in that order: a seeding that saved the cursor and then failed would leave a device holding a cursor past a conversation it never wrote, with no way to ask for it again. My first version had the comment saying that and the code doing the opposite. Mutation-checked: a document that silently drops the messages fails the round-trip test and nothing else. Backup still excluded, per the plan — that call is still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
@@ -133,9 +133,16 @@ joined-via-request. No messages.
|
||||
|
||||
- **Device migration** (`CordnMigrationStores`) is "this device becomes that
|
||||
device". Arriving with no history would be the surprising outcome. **Include.**
|
||||
*Done* — as `amethystMessages` on the group document, alongside the other
|
||||
additive `amethyst*` fields, written before the cursor on import for the same
|
||||
reason the live path writes it that way. Bounded per group by
|
||||
`MESSAGE_BUDGET_BYTES`, newest first: these blobs go to hosts whose limits we
|
||||
do not know, and a handoff that fails because one group is chatty is worse
|
||||
than one that carries a deep but bounded history.
|
||||
- **Backup** is a recovery artifact whose size the user sees. History could
|
||||
multiply it by a large factor. **Exclude for now**, and say so in the backup
|
||||
screen's copy rather than letting someone discover it at restore time.
|
||||
screen's copy rather than letting someone discover it at restore time. *Still
|
||||
open.*
|
||||
|
||||
Both are reversible later; the format is versioned.
|
||||
|
||||
|
||||
+4
@@ -113,6 +113,7 @@ class CordnMigration(
|
||||
roomState = group.roomStateBase64,
|
||||
echoState = group.echoStateBase64,
|
||||
joinedViaRequest = group.joinedViaRequest,
|
||||
messages = group.messages.takeIf { it.isNotEmpty() },
|
||||
coordinatorRelays = group.coordinatorRelays,
|
||||
)
|
||||
val blob = CordnDocumentSeal.seal(document, dek)
|
||||
@@ -252,6 +253,7 @@ class CordnMigration(
|
||||
roomStateBase64 = document.roomState,
|
||||
echoStateBase64 = document.echoState,
|
||||
joinedViaRequest = document.joinedViaRequest,
|
||||
messages = document.messages.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -335,4 +337,6 @@ data class CordnMigrationGroup(
|
||||
val roomStateBase64: String? = null,
|
||||
val echoStateBase64: String? = null,
|
||||
val joinedViaRequest: Boolean = false,
|
||||
/** The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. */
|
||||
val messages: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
+42
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.cordn
|
||||
|
||||
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage
|
||||
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec
|
||||
import com.vitorpamplona.quartz.cordn.sync.GroupCursor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -70,6 +71,7 @@ object CordnMigrationStores {
|
||||
roomStateBase64 = groupStore.loadRoomState(gid)?.let { CordnRoomStateCodec.encode(it).toBase64() },
|
||||
echoStateBase64 = groupStore.loadEchoState(gid)?.let { EchoStateCodec.encode(it).toBase64() },
|
||||
joinedViaRequest = groupStore.loadJoinedViaRequest(gid),
|
||||
messages = carriedMessages(groupStore, gid),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +84,39 @@ object CordnMigrationStores {
|
||||
return CordnMigrationSnapshot(accountPubKey, groups, keyPackages = keyPackages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly how much conversation one group contributes to a handoff.
|
||||
*
|
||||
* A migration document is sealed and uploaded to blob hosts whose limits we
|
||||
* do not know, and a handoff that fails because one group is chatty is a
|
||||
* worse outcome than one that carries a deep but bounded history. Budgeted
|
||||
* in bytes rather than messages because a single long message can cost as
|
||||
* much as a hundred short ones.
|
||||
*/
|
||||
private const val MESSAGE_BUDGET_BYTES = 512 * 1024
|
||||
|
||||
/**
|
||||
* The newest messages that fit the budget, back in oldest-first order.
|
||||
*
|
||||
* Newest-first while accumulating: if something has to be left behind it
|
||||
* should be the oldest part of the conversation, which is the part least
|
||||
* likely to be missed and the part a reader scrolls to last.
|
||||
*/
|
||||
private suspend fun carriedMessages(
|
||||
store: FileCordnGroupStore,
|
||||
gid: String,
|
||||
): List<String> {
|
||||
var budget = MESSAGE_BUDGET_BYTES
|
||||
return store
|
||||
.loadMessages(gid)
|
||||
.asReversed()
|
||||
.map { CordnDeliveredMessageCodec.encode(it) }
|
||||
.takeWhile { entry ->
|
||||
budget -= entry.length
|
||||
budget > 0
|
||||
}.asReversed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces this device's cordn tree with [snapshot]'s, returning the
|
||||
* coordinator list to start.
|
||||
@@ -106,6 +141,13 @@ object CordnMigrationStores {
|
||||
snapshot.groups.forEach { group ->
|
||||
val store = FileCordnGroupStore(CordnStorageLayout.directoryFor(root, accountPubKey, group.coordinatorPubKey), cipher)
|
||||
store.saveGroup(group.gid, group.clientStateBase64.fromBase64())
|
||||
// Before the cursor, for the same reason the live path writes them in
|
||||
// that order: a seeding that wrote the cursor and then failed would
|
||||
// leave a device holding a cursor past a conversation it never
|
||||
// wrote, with no way to ask for it again.
|
||||
group.messages.forEach { entry ->
|
||||
CordnDeliveredMessageCodec.decodeOrNull(entry)?.let { store.appendMessage(group.gid, it) }
|
||||
}
|
||||
// Both halves of the cursor: the writer's snapshot was consistent at
|
||||
// fetchCursor (§4.1), and starting behind it would re-fetch
|
||||
// messages the state has already advanced past.
|
||||
|
||||
+49
@@ -28,6 +28,9 @@ import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
|
||||
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage
|
||||
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry
|
||||
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory
|
||||
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
|
||||
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec
|
||||
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
@@ -61,6 +64,17 @@ import org.junit.Test
|
||||
*/
|
||||
class CordnMigrationTest {
|
||||
private val account = NostrSignerInternal(KeyPair())
|
||||
|
||||
/** Real codec output, so this travels the same bytes the store writes. */
|
||||
private val entryOne =
|
||||
CordnDeliveredMessageCodec.encode(
|
||||
CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_000L, 9, content = "first"), cursor = 1),
|
||||
)
|
||||
|
||||
private val entryTwo =
|
||||
CordnDeliveredMessageCodec.encode(
|
||||
CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_060L, 9, content = "second"), cursor = 2),
|
||||
)
|
||||
private val relay = RelayUrlNormalizer.normalize("wss://tip.example")
|
||||
|
||||
private val snapshot =
|
||||
@@ -110,6 +124,39 @@ class CordnMigrationTest {
|
||||
assertEquals(listOf("wss://coord.example"), received.groups[0].coordinatorRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the conversation travels, because nothing else can carry it`() =
|
||||
runTest {
|
||||
// The one piece of a handoff that has no second source. A group's
|
||||
// MLS state can be re-derived from the coordinator's stream and a
|
||||
// KeyPackage can be republished, but a cordn message is readable
|
||||
// exactly once — at ingest — and the cursor in this very document
|
||||
// has already moved past it. A device seeded without the messages
|
||||
// arrives holding every group and no conversation, for good.
|
||||
val world = World(this)
|
||||
val withHistory =
|
||||
snapshot.copy(
|
||||
groups = listOf(group("gid-1", cursor = 7, messages = listOf(entryOne, entryTwo))),
|
||||
)
|
||||
|
||||
val received = world.new(account).fetch(world.old().publish(withHistory, setOf(relay)))
|
||||
|
||||
assertEquals(listOf(entryOne, entryTwo), received.groups[0].messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group with no history carries no messages field`() =
|
||||
runTest {
|
||||
// The field is additive, so an empty list must not become an empty
|
||||
// array in the document and come back as something other than what
|
||||
// went in — the round-trip equality above depends on it.
|
||||
val world = World(this)
|
||||
|
||||
val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay)))
|
||||
|
||||
assertEquals(emptyList<String>(), received.groups[0].messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the key packages travel, so a Welcome in flight is not lost`() =
|
||||
runTest {
|
||||
@@ -265,6 +312,7 @@ class CordnMigrationTest {
|
||||
gid: String,
|
||||
cursor: Long,
|
||||
joinedViaRequest: Boolean = false,
|
||||
messages: List<String> = emptyList(),
|
||||
) = CordnMigrationGroup(
|
||||
coordinatorPubKey = "cc".repeat(32),
|
||||
coordinatorRelays = listOf("wss://coord.example"),
|
||||
@@ -274,6 +322,7 @@ class CordnMigrationTest {
|
||||
roomStateBase64 = "cm9vbQ==",
|
||||
echoStateBase64 = "ZWNobw==",
|
||||
joinedViaRequest = joinedViaRequest,
|
||||
messages = messages,
|
||||
)
|
||||
|
||||
/** The two phones, one relay and one storage server, in memory. */
|
||||
|
||||
+16
@@ -85,6 +85,7 @@ object CordnDeviceDocument {
|
||||
private const val ROOM_STATE = "amethystRoomState"
|
||||
private const val ECHO_STATE = "amethystEchoState"
|
||||
private const val JOINED_VIA_REQUEST = "amethystJoinedViaRequest"
|
||||
private const val MESSAGES = "amethystMessages"
|
||||
private const val COORDINATOR_RELAYS = "amethystCoordinatorRelays"
|
||||
private const val KEY_PACKAGES = "amethystKeyPackages"
|
||||
private const val COORDINATOR_PUBKEY = "coordinator"
|
||||
@@ -146,6 +147,9 @@ object CordnDeviceDocument {
|
||||
put(CLIENT_STATE_FORMAT_FIELD, document.clientStateFormat)
|
||||
put(CURSOR, document.cursor)
|
||||
document.roomState?.let { put(ROOM_STATE, it) }
|
||||
document.messages
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { entries -> put(MESSAGES, buildJsonArray { entries.forEach { add(JsonPrimitive(it)) } }) }
|
||||
document.echoState?.let { put(ECHO_STATE, it) }
|
||||
if (document.joinedViaRequest) put(JOINED_VIA_REQUEST, true)
|
||||
if (document.coordinatorRelays.isNotEmpty()) {
|
||||
@@ -169,6 +173,7 @@ object CordnDeviceDocument {
|
||||
// client. Either way it is not ours; say so rather than guess.
|
||||
clientStateFormat = root.stringOrNull(CLIENT_STATE_FORMAT_FIELD),
|
||||
roomState = root.stringOrNull(ROOM_STATE),
|
||||
messages = (root[MESSAGES] as? JsonArray)?.map { (it as JsonPrimitive).content },
|
||||
echoState = root.stringOrNull(ECHO_STATE),
|
||||
joinedViaRequest = root.boolOrNull(JOINED_VIA_REQUEST) ?: false,
|
||||
coordinatorRelays =
|
||||
@@ -326,6 +331,17 @@ data class CordnGroupDocument(
|
||||
* half-typed message gone.
|
||||
*/
|
||||
val roomState: String? = null,
|
||||
/**
|
||||
* The conversation, as `CordnDeliveredMessageCodec` entries, oldest first.
|
||||
*
|
||||
* Additive like [roomState], and the most load-bearing of the additions. A
|
||||
* cordn message is readable exactly once — at ingest — because both of its
|
||||
* seal keys are epoch-derived and the [cursor] this document carries has
|
||||
* already advanced past everything behind it. A device seeded without this
|
||||
* cannot fetch the history back from anywhere: it would arrive holding
|
||||
* every group and no conversation, permanently.
|
||||
*/
|
||||
val messages: List<String>? = null,
|
||||
/**
|
||||
* `base64(EchoStateCodec)` — pending commits and own-message cursors.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user