D4: history the user can carry — encrypted export and import

Same design as iOS: the vault's history re-sealed under a passphrase-
derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged
back by content identity so the same backup twice adds nothing. Expired
stories stay expired — a backup is not a way back in. The passphrase is
stored nowhere, deliberately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 20:29:22 -04:00
co-authored by Claude Opus 4.8
parent f1122777c1
commit 6a0f642e0d
6 changed files with 599 additions and 0 deletions
@@ -660,6 +660,9 @@ fun ColumnScope.AccountScreen(
SectionBand() SectionBand()
SettingsSection { RegisteredDevicesSection(model, phase) } SettingsSection { RegisteredDevicesSection(model, phase) }
SectionBand()
SettingsSection { HistoryBackupSection(model) }
SectionBand() SectionBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) } SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
@@ -0,0 +1,230 @@
package io.flamenet.messenger.app
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.HistoryExport
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Export and import of encrypted history backups (D4).
*
* The relay keeps no history, so a new phone starts empty — this is the
* remedy, and the passphrase is deliberately the only key: the app stores
* nothing that could open the file, so neither can anyone who takes the file.
*/
@Composable
fun HistoryBackupSection(model: MessengerViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showExportDialog by remember { mutableStateOf(false) }
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
var pendingImport by remember { mutableStateOf<Uri?>(null) }
var working by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
val bytes = pendingExport
pendingExport = null
if (uri == null || bytes == null) return@rememberLauncherForActivityResult
status = runCatching {
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
?: error("nothing to write to")
}.fold(
onSuccess = { "Backup saved. Keep the passphrase — it is the only key." },
onFailure = { "Couldn't write the file: ${it.message}" },
)
}
val openLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> if (uri != null) pendingImport = uri }
Text("History backup", style = MaterialTheme.typography.titleMedium)
Text(
"Your history lives only on this phone — the server keeps none, so a "
+ "new phone starts empty. An export is sealed with a passphrase you "
+ "choose and can be imported on another Android phone signed into "
+ "this account. Lose the passphrase and the backup cannot be "
+ "opened, by anyone.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Row {
OutlinedButton(
onClick = { showExportDialog = true },
enabled = !working,
modifier = Modifier.testTag("export-history"),
) { Text("Export…") }
Spacer(Modifier.width(Space.sm))
OutlinedButton(
// SAF filters on MIME, and a custom extension has none — offer
// everything and let the crypto refuse what isn't a backup.
onClick = { openLauncher.launch(arrayOf("*/*")) },
enabled = !working,
modifier = Modifier.testTag("import-history"),
) { Text("Import…") }
}
status?.let {
Spacer(Modifier.height(Space.xs))
Text(it, fontSize = 12.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("backup-status"))
}
if (showExportDialog) {
PassphraseDialog(
title = "Export history",
explainer = "This passphrase is the only key to the backup. It isn't "
+ "stored anywhere — a forgotten passphrase means the backup can "
+ "never be opened.",
requireRepeat = true,
busy = working,
onDismiss = { if (!working) showExportDialog = false },
) { phrase ->
working = true
scope.launch {
val bytes = model.exportHistory(phrase.toCharArray())
working = false
showExportDialog = false
if (bytes == null) {
status = "Sign in first."
} else {
pendingExport = bytes
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
saveLauncher.launch("flamenet-history-$stamp.${HistoryExport.FILE_EXTENSION}")
}
}
}
}
pendingImport?.let { uri ->
PassphraseDialog(
title = "Import history",
explainer = "Anything already on this phone stays — the backup is "
+ "merged in, never a replacement.",
requireRepeat = false,
busy = working,
onDismiss = { if (!working) pendingImport = null },
) { phrase ->
working = true
scope.launch {
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes == null) {
working = false
pendingImport = null
status = "Couldn't read that file."
return@launch
}
val outcome = model.importHistory(bytes, phrase.toCharArray())
working = false
pendingImport = null
status = outcome.fold(
onSuccess = { counts ->
if (counts.messages + counts.stories == 0)
"Backup opened — everything in it was already here."
else
"Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" +
" and ${counts.stories} " +
if (counts.stories == 1) "story." else "stories."
},
onFailure = { it.message ?: "That backup couldn't be opened." },
)
}
}
}
}
@Composable
private fun PassphraseDialog(
title: String,
explainer: String,
requireRepeat: Boolean,
busy: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var phrase by remember { mutableStateOf("") }
var repeat by remember { mutableStateOf("") }
val mismatch = requireRepeat && repeat.isNotEmpty() && repeat != phrase
val ready = phrase.length >= 6 && (!requireRepeat || repeat == phrase)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
androidx.compose.foundation.layout.Column {
Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = phrase,
onValueChange = { phrase = it },
placeholder = { Text("Passphrase", fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"),
)
if (requireRepeat) {
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
value = repeat,
onValueChange = { repeat = it },
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = mismatch,
modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"),
)
if (mismatch) {
Text("The two passphrases don't match yet.",
fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
}
if (phrase.isNotEmpty() && phrase.length < 6) {
Text("Use at least 6 characters.",
fontSize = 12.sp, color = Palette.SecondaryText)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(phrase) },
enabled = ready && !busy,
modifier = Modifier.testTag("backup-confirm"),
) { Text(if (busy) "Working…" else "Continue") }
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
},
)
}
@@ -12,6 +12,7 @@ import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID import io.flamenet.e2e.FNID
import io.flamenet.e2e.HistoryExport
import io.flamenet.e2e.Vault import io.flamenet.e2e.Vault
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import io.flamenet.client.IdentityTrust import io.flamenet.client.IdentityTrust
@@ -1251,6 +1252,31 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
// -- History backup (D4) -------------------------------------------------
/**
* The vault's history, sealed under a passphrase the user chose.
*
* Off the main thread on purpose: the KDF runs 600k rounds, which is the
* point of it, and would freeze the UI for a visible beat.
*/
suspend fun exportHistory(passphrase: CharArray): ByteArray? =
withContext(Dispatchers.Default) {
vault?.let { HistoryExport.seal(it.exportPayload(), passphrase) }
}
/** Merge a backup file in. Returns how much of it was actually new. */
suspend fun importHistory(bytes: ByteArray, passphrase: CharArray): Result<Vault.ImportCounts> =
withContext(Dispatchers.Default) {
val v = vault
?: return@withContext Result.failure(IllegalStateException("Sign in first."))
runCatching {
v.mergeImportedPayload(
HistoryExport.open(bytes, passphrase),
nowSeconds = System.currentTimeMillis() / 1000.0)
}
}
// -- Stories (§6.6) ------------------------------------------------------ // -- Stories (§6.6) ------------------------------------------------------
fun refreshStories() = viewModelScope.launch { fun refreshStories() = viewModelScope.launch {
@@ -0,0 +1,93 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* A history backup the platform cannot read.
*
* The relay keeps no history — that is the product's whole promise — which
* means a new device genuinely starts empty. This is the designed remedy: the
* vault's history, re-sealed under a key derived from a passphrase the user
* chooses, so the file is safe to park anywhere and useless without the words.
*
* Same envelope as the iOS app (a JSON header naming the KDF around one
* AES-256-GCM box), but the v1 payload is platform-shaped, so the envelope is
* platform-tagged and a mismatched import is refused with an honest message
* rather than half-parsed.
*
* ⚠️ The passphrase is the only key. It is not stored anywhere — not in the
* Keystore, not in the file. A forgotten passphrase means the backup is gone,
* and that is the design, not a gap.
*/
object HistoryExport {
const val FILE_EXTENSION = "fnhistory"
/** High on purpose: runs once per export, prices out billions of guesses. */
private const val ITERATIONS = 600_000
private const val SALT_LEN = 16
private const val NONCE_LEN = 12
private const val TAG_BITS = 128
class NotAnExport : Exception("That file is not a Flamenet history export.")
class WrongPlatform(val platform: String) :
Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform} — import it on a device of the same kind.")
class WrongPassphrase : Exception("That passphrase doesn't open this backup.")
fun seal(payload: JSONObject, passphrase: CharArray): ByteArray {
val salt = ByteArray(SALT_LEN).also(SecureRandom()::nextBytes)
val key = derive(passphrase, salt, ITERATIONS)
val nonce = ByteArray(NONCE_LEN).also(SecureRandom()::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce))
val sealed = cipher.doFinal(payload.toString().toByteArray(Charsets.UTF_8))
val envelope = JSONObject()
.put("magic", "fnhistory")
.put("v", 1)
.put("platform", "android")
.put("kdf", JSONObject()
.put("alg", "pbkdf2-sha256")
.put("salt", Bytes.b64(salt))
.put("iters", ITERATIONS))
.put("box", Bytes.b64(nonce + sealed))
return envelope.toString().toByteArray(Charsets.UTF_8)
}
fun open(bytes: ByteArray, passphrase: CharArray): JSONObject {
val envelope = runCatching { JSONObject(String(bytes, Charsets.UTF_8)) }
.getOrNull() ?: throw NotAnExport()
val kdf = envelope.optJSONObject("kdf")
if (envelope.optString("magic") != "fnhistory" || envelope.optInt("v") != 1 ||
kdf == null || kdf.optString("alg") != "pbkdf2-sha256"
) throw NotAnExport()
val platform = envelope.optString("platform")
if (platform != "android") throw WrongPlatform(platform)
val salt = runCatching { Bytes.unb64(kdf.getString("salt")) }.getOrNull()
val combined = runCatching { Bytes.unb64(envelope.getString("box")) }.getOrNull()
if (salt == null || combined == null || combined.size <= NONCE_LEN) throw NotAnExport()
// The count comes from the file so an old export still opens after the
// default rises — clamped, because a hostile file naming ten billion
// rounds would wedge the import forever.
val iters = kdf.optInt("iters", ITERATIONS).coerceIn(100_000, 2_000_000)
val key = derive(passphrase, salt, iters)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"),
GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN)))
val plain = runCatching {
cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size))
}.getOrNull() ?: throw WrongPassphrase()
return runCatching { JSONObject(String(plain, Charsets.UTF_8)) }
.getOrNull() ?: throw NotAnExport()
}
private fun derive(passphrase: CharArray, salt: ByteArray, iterations: Int): ByteArray =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)).encoded
}
@@ -576,6 +576,112 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun replaceHistory(threadId: String, messages: JSONArray) = fun replaceHistory(threadId: String, messages: JSONArray) =
writeJson(historyName(threadId), JSONObject().put("messages", messages)) writeJson(historyName(threadId), JSONObject().put("messages", messages))
// -- History backup (D4) -------------------------------------------------
/**
* Everything a history backup carries, as one JSON value.
*
* The outbox is deliberately left out: a pending send belongs to the
* device that queued it, and importing one elsewhere would either send a
* duplicate or strand a ghost that can never be flushed. Sessions, trust
* and keys are left out too — a backup restores what was said, never who
* this device is.
*/
fun exportPayload(): JSONObject {
val threadsObj = JSONObject()
threads().forEach { tid -> threadsObj.put(tid, history(tid)) }
return JSONObject()
.put("threads", threadsObj)
.put("callLog", callLog())
.also { p -> readJson(STORIES)?.let { p.put("stories", it) } }
}
data class ImportCounts(val messages: Int, val stories: Int)
/**
* Merge a backup into what this device already has. Returns how much of
* it was actually new.
*
* Merge, never replace: the device has been receiving mail since it
* signed in, and an import that overwrote those rows would trade old
* history for new. Lines deduplicate by content (mine + timestamp + text
* + attachment), stories by their real id, calls by peer + timestamp —
* and the same backup imported twice adds nothing the second time.
*/
fun mergeImportedPayload(payload: JSONObject, nowSeconds: Double): ImportCounts {
var newMessages = 0
val threadsObj = payload.optJSONObject("threads") ?: JSONObject()
for (tid in threadsObj.keys()) {
val incoming = threadsObj.optJSONArray(tid) ?: continue
val current = history(tid)
val seen = HashSet<String>()
for (i in 0 until current.length()) seen.add(lineKey(current.getJSONObject(i)))
val fresh = ArrayList<JSONObject>()
for (i in 0 until incoming.length()) {
val m = incoming.optJSONObject(i) ?: continue
if (seen.add(lineKey(m))) fresh.add(m)
}
if (fresh.isEmpty()) continue
newMessages += fresh.size
val merged = ArrayList<JSONObject>(current.length() + fresh.size)
for (i in 0 until current.length()) merged.add(current.getJSONObject(i))
merged.addAll(fresh)
merged.sortBy { it.optLong("at") }
replaceHistory(tid, JSONArray().also { arr -> merged.forEach(arr::put) })
}
var newStories = 0
val importedStories = payload.optJSONObject("stories")?.optJSONArray("items")
if (importedStories != null && importedStories.length() > 0) {
val current = readJson(STORIES)?.optJSONArray("items") ?: JSONArray()
val known = HashSet<String>()
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id"))
for (i in 0 until importedStories.length()) {
val s = importedStories.optJSONObject(i) ?: continue
// An expired story must not sneak back in through a backup —
// the expiry is a promise about the bytes leaving the device.
val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0
val active = exp == 0.0 || nowSeconds < exp
if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) {
current.put(s)
newStories++
}
}
writeJson(STORIES, JSONObject().put("items", current))
}
val importedCalls = payload.optJSONArray("callLog")
if (importedCalls != null && importedCalls.length() > 0) {
val current = callLog()
val known = HashSet<String>()
for (i in 0 until current.length()) {
val c = current.getJSONObject(i)
known.add("${c.optLong("peer")}|${c.optLong("at")}")
}
var changed = false
for (i in 0 until importedCalls.length()) {
val c = importedCalls.optJSONObject(i) ?: continue
if (known.add("${c.optLong("peer")}|${c.optLong("at")}")) {
current.put(c)
changed = true
}
}
if (changed) {
val sorted = ArrayList<JSONObject>(current.length())
for (i in 0 until current.length()) sorted.add(current.getJSONObject(i))
sorted.sortBy { it.optLong("at") }
saveCallLog(JSONArray().also { arr -> sorted.forEach(arr::put) })
}
}
return ImportCounts(newMessages, newStories)
}
/** What makes a line *the same message* across two devices. */
private fun lineKey(m: JSONObject): String =
"${m.optBoolean("mine")}|${m.optLong("at")}|${m.optString("text")}|" +
(m.optJSONObject("att")?.optString("id") ?: "")
/** Every thread this device holds, group or person. */ /** Every thread this device holds, group or person. */
fun threads(): List<String> = fun threads(): List<String> =
storage.names().filter { it.startsWith(HISTORY_PREFIX) } storage.names().filter { it.startsWith(HISTORY_PREFIX) }
@@ -0,0 +1,141 @@
package io.flamenet.e2e
import org.json.JSONArray
import org.json.JSONObject
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* The history backup opens only with the right words, and merging never loses
* what a device already has. The dedupe rules are the part worth guarding:
* the same backup imported twice must add nothing the second time.
*
* ⚠️ These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform
* `Cipher`) is deliberately the same JCA surface the vault already exercises
* on Android — but per the Conscrypt rule, the on-device instrumentation
* suite is what proves the Android side.
*/
class HistoryExportTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vault() = Vault(MapStorage(), key())
private fun line(text: String, mine: Boolean, at: Long): JSONObject =
JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at)
private fun populated(): Vault {
val v = vault()
v.append("user:42", line("packing now", true, 1_756_400_000_000))
v.append("user:42", line("bring the charger", false, 1_756_400_060_000))
v.append("group:g1", line("meeting moved", false, 1_756_400_120_000))
v.saveCallLog(JSONArray().put(JSONObject()
.put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000)))
return v
}
// -- Sealing --------------------------------------------------------------
@Test
fun `a backup round trips with the right passphrase`() {
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
val back = HistoryExport.open(sealed, "correct horse".toCharArray())
assertEquals(2, back.getJSONObject("threads").getJSONArray("user:42").length())
assertEquals(1, back.getJSONObject("threads").getJSONArray("group:g1").length())
assertEquals(1, back.getJSONArray("callLog").length())
}
@Test
fun `the wrong passphrase is refused as exactly that`() {
val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray())
assertFailsWith<HistoryExport.WrongPassphrase> {
HistoryExport.open(sealed, "wrong pony".toCharArray())
}
}
@Test
fun `garbage is not an export`() {
assertFailsWith<HistoryExport.NotAnExport> {
HistoryExport.open("not json".toByteArray(), "x".toCharArray())
}
}
@Test
fun `an iPhone backup is refused honestly`() {
val sealed = HistoryExport.seal(populated().exportPayload(), "pw-123456".toCharArray())
val doctored = JSONObject(String(sealed)).put("platform", "ios")
assertFailsWith<HistoryExport.WrongPlatform> {
HistoryExport.open(doctored.toString().toByteArray(), "pw-123456".toCharArray())
}
}
@Test
fun `the outbox never leaves the device`() {
val v = populated()
v.saveOutbox(JSONArray().put(JSONObject().put("to", 42).put("text", "pending")))
assertTrue(!v.exportPayload().has("outbox"))
}
// -- Merging --------------------------------------------------------------
@Test
fun `merging into an empty device takes everything`() {
val fresh = vault()
val counts = fresh.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
assertEquals(3, counts.messages)
assertEquals(2, fresh.history("user:42").length())
assertEquals(1, fresh.callLog().length())
}
@Test
fun `merging keeps what the device already has`() {
val device = vault()
device.append("user:42", line("sent after the export", false, 1_756_500_000_000))
device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0)
val texts = device.history("user:42").let { arr ->
(0 until arr.length()).map { arr.getJSONObject(it).getString("text") }
}
assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts)
}
@Test
fun `the same backup twice adds nothing the second time`() {
val device = vault()
val payload = populated().exportPayload()
device.mergeImportedPayload(payload, nowSeconds = 0.0)
val second = device.mergeImportedPayload(payload, nowSeconds = 0.0)
assertEquals(0, second.messages)
assertEquals(0, second.stories)
assertEquals(2, device.history("user:42").length())
assertEquals(1, device.callLog().length())
}
@Test
fun `an expired story does not sneak back in through a backup`() {
val source = vault()
val items = JSONArray()
.put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5)
.put("ref", JSONObject().put("exp", 0.0)))
.put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6)
.put("ref", JSONObject().put("exp", 100.0)))
// Write through the raw record shape the vault itself uses.
val payload = source.exportPayload().put("stories", JSONObject().put("items", items))
val device = vault()
val counts = device.mergeImportedPayload(payload, nowSeconds = 999.0)
assertEquals(1, counts.stories)
}
}