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:
Claude
2026-09-24 15:47:20 +00:00
parent e4dc1c52d4
commit 53de512559
5 changed files with 119 additions and 1 deletions
@@ -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.
@@ -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(),
)
@@ -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.
@@ -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. */
@@ -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.
*