From 53de5125597af0e5c9f053aa5ddf9881a81d33e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:47:20 +0000 Subject: [PATCH] feat(cordn): carry the conversation through a device handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../plans/2026-09-24-cordn-message-store.md | 9 +++- .../amethyst/commons/cordn/CordnMigration.kt | 4 ++ .../commons/cordn/CordnMigrationStores.kt | 42 ++++++++++++++++ .../commons/cordn/CordnMigrationTest.kt | 49 +++++++++++++++++++ .../appMultiDevice/CordnDeviceDocument.kt | 16 ++++++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/commons/plans/2026-09-24-cordn-message-store.md b/commons/plans/2026-09-24-cordn-message-store.md index 1ec5a46088..07f326a7b7 100644 --- a/commons/plans/2026-09-24-cordn-message-store.md +++ b/commons/plans/2026-09-24-cordn-message-store.md @@ -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. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt index 88fe3328f1..ce25ee73f8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt @@ -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 = emptyList(), ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt index 2fab1468f5..492cd7ccdb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt @@ -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 { + 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. diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt index e7de9c1c08..76ffe85e4f 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt @@ -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(), 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 = 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. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt index 65665703f5..184dfa77ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt @@ -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? = null, /** * `base64(EchoStateCodec)` — pending commits and own-message cursors. *