Believe the relay about its own roster, including an empty one

Same as the iPhone. Yesterday's guard refused every empty list and stranded a
removed last contact on every other device; the relay says which kind of empty
it means now, so the rule is simply whether the relay is the one holding the
roster.

MessengerApi.roster() carries both halves; buddies() stays for callers that
only want the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 11:15:26 -04:00
co-authored by Claude Opus 5
parent dafb4f8adb
commit 55d8442ccc
3 changed files with 74 additions and 27 deletions
@@ -2104,13 +2104,19 @@ 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 (!shouldAdoptRoster(incoming = fetched.size, held = items.length())) {
val answer = api.roster()
val fetched = answer.buddies
// §8.9c. A relay that no longer keeps the contact graph is not the
// source of one, whatever it sends, so its answer is ignored and
// the stored roster stands. A relay that DOES keep it is the
// source, so its answer is taken even when empty: an account that
// removed its last contact has an empty list.
if (!shouldAdoptRoster(
incoming = fetched.size,
held = items.length(),
relayKeepsRoster = answer.relayKeepsRoster,
)
) {
return stored()
}
val mirror = org.json.JSONArray()
@@ -2168,18 +2174,26 @@ class Session(
internal fun shouldAdoptRoster(
incoming: Int,
held: Int,
): Boolean = shouldAdoptRosterForTest(incoming, held)
relayKeepsRoster: Boolean,
): Boolean = shouldAdoptRosterForTest(incoming, held, relayKeepsRoster)
companion object {
/**
* The rule, reachable without building a Session so a test can ask it
* directly rather than grep for it.
*
* A relay that keeps no contact graph is not a source of one, whatever
* it sends. One that does keep it is the source, so its answer stands
* including an empty one. `incoming` and `held` are still taken so the
* shape survives if a third rule is ever needed, and so a caller
* cannot pass the wrong thing by position.
*/
@JvmStatic
internal fun shouldAdoptRosterForTest(
incoming: Int,
held: Int,
): Boolean = incoming > 0 || held == 0
relayKeepsRoster: Boolean,
): Boolean = relayKeepsRoster
}
private fun presenceFromTokens(): Map<Long, String> {
@@ -27,37 +27,47 @@ class AnEmptyRosterNeverErasesOneTest {
* somebody's contact list.
*/
@Test
fun `an empty list never replaces a roster that is held`() {
assertFalse(Session.shouldAdoptRosterForTest(incoming = 0, held = 7))
assertFalse(Session.shouldAdoptRosterForTest(incoming = 0, held = 1))
fun `a relay that keeps no roster is never adopted from`() {
for ((incoming, held) in listOf(0 to 7, 0 to 0, 3 to 7, 3 to 0)) {
assertFalse(
Session.shouldAdoptRosterForTest(incoming, held, relayKeepsRoster = false),
"adopted $incoming over $held from a relay that keeps none",
)
}
}
/**
* Empty onto empty is allowed, or a new account could never take its first
* contacts from the server.
* When the relay IS the source its answer stands, including an empty one:
* an account that removed its last contact has an empty list and should
* see one. Before the relay said which kind of empty it meant, every empty
* answer had to be refused, which left a removed last contact on screen on
* every other device for ever.
*/
@Test
fun `an empty store still accepts an empty answer`() {
assertTrue(Session.shouldAdoptRosterForTest(incoming = 0, held = 0))
fun `a relay that keeps the roster is believed, empty included`() {
assertTrue(Session.shouldAdoptRosterForTest(0, 7, relayKeepsRoster = true))
assertTrue(Session.shouldAdoptRosterForTest(0, 0, relayKeepsRoster = true))
assertTrue(Session.shouldAdoptRosterForTest(9, 1, relayKeepsRoster = true))
}
/**
* And a real list still lands. A guard that refused everything would pass
* the test above and break the app.
*/
/** An older relay cannot say, and absent has to read as "still keeps one". */
@Test
fun `a list with anything in it is always adopted`() {
assertTrue(Session.shouldAdoptRosterForTest(incoming = 1, held = 0))
assertTrue(Session.shouldAdoptRosterForTest(incoming = 1, held = 9))
fun `a relay that says nothing is taken to keep one`() {
val api =
File("../client/src/main/kotlin/io/flamenet/client/MessengerApi.kt").readText()
assertTrue(
api.contains("optBoolean(\"roster_on_server\", true)"),
"an older relay would be read as keeping no roster",
)
}
/**
* The guard has to sit before the mirror is written, or the empty list is
* The guard has to sit before the mirror is written, or the answer is
* saved on the way past and the fallback reads it back.
*/
@Test
fun `the guard comes before the write`() {
val guard = session.indexOf("shouldAdoptRoster(incoming = fetched.size")
val guard = session.indexOf("shouldAdoptRoster(")
val write = session.indexOf("vault.saveRoster(mirror, offServer = false)")
assertTrue(guard in 0..<write, "guard at $guard, write at $write")
}
@@ -44,8 +44,31 @@ class MessengerApi(
val read: Boolean,
)
fun buddies(): List<Buddy> {
val list = get("buddies").optJSONArray("buddies") ?: JSONArray()
/**
* The buddy list, and whether the relay is still the one holding it.
*
* §8.9c. A relay that has stopped keeping a contact graph answers with an
* empty list, and so does an account that genuinely has no contacts. They
* are different things and only the relay can tell them apart, so it says
* which. Absent on an older relay, and absent reads as "still keeps one",
* because that is what an older relay does.
*/
data class Roster(
val buddies: List<Buddy>,
val relayKeepsRoster: Boolean,
)
fun roster(): Roster {
val body = get("buddies")
return Roster(
buddies = parseBuddies(body.optJSONArray("buddies") ?: JSONArray()),
relayKeepsRoster = body.optBoolean("roster_on_server", true),
)
}
fun buddies(): List<Buddy> = roster().buddies
private fun parseBuddies(list: JSONArray): List<Buddy> {
return (0 until list.length()).map {
val b = list.getJSONObject(it)
// `id`, `display_name`, `screen_name`, `presence`, this route does