An empty roster from the relay never erases the one stored

Same guard as the iPhone. A relay that has stopped keeping the contact graph
answers with an empty list, and mirroring that over the stored roster would
erase somebody's contacts on the first poll after the switch was thrown, or on
any transient fault that answered empty.

The guard sits before the mirror is written, or the empty list would be saved
on the way past and the fallback would read it back. A test pins that ordering
rather than only the presence of the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-15 18:04:35 -04:00
co-authored by Claude Opus 5
parent cf811d1bee
commit e48f726c0f
2 changed files with 59 additions and 0 deletions
@@ -2105,6 +2105,12 @@ class Session(
if (offServer) return stored()
return try {
val fetched = api.buddies()
// §8.9c. An empty answer means this relay no longer keeps the
// contact graph, not that the contacts are gone. Taken literally
// it would erase the stored roster on the first poll after an
// operator threw that switch, and on any transient fault that
// answered empty. Nothing on the server can empty a roster now.
if (fetched.isEmpty() && items.length() > 0) return stored()
val mirror = org.json.JSONArray()
fetched.forEach { b ->
mirror.put(
@@ -0,0 +1,53 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* §8.9c ends with the relay keeping no contact graph, and a relay that has
* stopped answers the buddies route with an empty list.
*
* Taken literally that erases the stored roster on the first poll after an
* operator throws the switch, and on any transient fault that answers empty.
* So an empty answer never replaces a roster already held. Mirrors
* `AnEmptyRosterNeverErasesOneTests` on iOS.
*/
class AnEmptyRosterNeverErasesOneTest {
private val session =
File("src/main/kotlin/io/flamenet/messenger/app/Session.kt").let {
assertTrue(it.isFile, "this test reads app/src/main/kotlin/.../Session.kt")
it.readText()
}
@Test
fun `an empty answer falls back to what is stored`() {
assertTrue(
session.contains("if (fetched.isEmpty() && items.length() > 0) return stored()"),
"an empty server answer would be mirrored over the stored roster",
)
}
/**
* The guard has to sit before the mirror is written, or the empty list is
* saved on the way past and the fallback reads it back.
*/
@Test
fun `the guard comes before the write`() {
val guard = session.indexOf("if (fetched.isEmpty() && items.length() > 0)")
val write = session.indexOf("vault.saveRoster(mirror, offServer = false)")
assertTrue(guard in 0..<write, "guard at $guard, write at $write")
}
/**
* An empty list is still adoptable onto an empty store, or a new account
* could never take its first contact from the server.
*/
@Test
fun `an empty store still accepts an empty answer`() {
assertTrue(
session.contains("items.length() > 0"),
"the guard must only fire when something is actually held",
)
}
}