Off the record: edits, reactions and pins work, and none of them reach disk

`record()` and `history()` knew an off-the-record thread lives in memory.
`applyEdit` and `applyReaction` did not. They read `vault.history`, found
nothing there for such a thread, and returned false, so an edit or a
reaction to an off-the-record message was silently dropped. Not leaked,
dropped: the better of the two faults, and still a broken feature.

`applyPin` went the other way and wrote the pin to the vault regardless, a
record on disk naming the thread for a conversation whose one promise is
that nothing reaches disk.

One helper now mutates a thread's lines where they actually live, memory
or vault, and both mutators use it; a pin on an off-the-record thread is
held beside the thread in memory. The comment on `record()` claiming a
single gate was enough for the writes it knew about, and these were the
writes it did not.

Falsified one at a time: each old behaviour restored fails exactly its own
test and no other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-17 05:22:44 -04:00
co-authored by Claude Fable 5.1
parent 4235754d0c
commit 6c4289ee42
2 changed files with 157 additions and 10 deletions
@@ -749,15 +749,40 @@ class Session(
* A private one is kept apart from the public tally, so the author can see
* it was meant for them alone and it never reads as something the group saw.
*/
/**
* Mutate a thread's lines where they actually live.
*
* An off-the-record thread is held in `ephemeral` and never reaches the
* vault; `record()` knows that and `history()` knows that, and these two
* did not. They read `vault.history`, found nothing there for such a
* thread, and returned false: an edit or a reaction to an off-the-record
* message was silently dropped. Not leaked, dropped, which is the better
* of the two faults and still a broken feature. The block returns whether
* it changed anything; the write-back happens only for a vaulted thread,
* because the in-memory array IS the live one.
*/
private fun mutateHistory(
threadId: String,
block: (org.json.JSONArray) -> Boolean,
): Boolean {
val inMemory = ephemeral[threadId]
val stored = inMemory ?: vault.history(threadId)
val hit = block(stored)
if (hit && inMemory == null) vault.replaceHistory(threadId, stored)
return hit
}
/** Pins for off-the-record threads, which must never reach the vault. */
private val ephemeralPins = mutableMapOf<String, String>()
fun applyReaction(
threadId: String,
messageId: String,
author: Long,
emoji: String,
private: Boolean = false,
): Boolean {
): Boolean = mutateHistory(threadId) { stored ->
val key = if (private) "rxp" else "rx"
val stored = vault.history(threadId)
var hit = false
for (i in 0 until stored.length()) {
val row = stored.getJSONObject(i)
@@ -781,8 +806,7 @@ class Session(
if (rebuilt.length() > 0) row.put(key, rebuilt) else row.remove(key)
break
}
if (hit) vault.replaceHistory(threadId, stored)
return hit
hit
}
/**
@@ -794,8 +818,7 @@ class Session(
author: Long,
newText: String,
at: Long = System.currentTimeMillis(),
): Boolean {
val stored = vault.history(threadId)
): Boolean = mutateHistory(threadId) { stored ->
var hit = false
for (i in 0 until stored.length()) {
val row = stored.getJSONObject(i)
@@ -812,14 +835,14 @@ class Session(
hit = true
break
}
if (hit) vault.replaceHistory(threadId, stored)
return hit
hit
}
/**
* The message pinned in a thread, or null (§6.19).
*/
fun pinnedMessage(threadId: String): String? = vault.pinnedMessage(threadId)
fun pinnedMessage(threadId: String): String? =
if (vault.offTheRecord(threadId)) ephemeralPins[threadId] else vault.pinnedMessage(threadId)
/**
* Pin a message, or take the pin down with an empty [messageId].
@@ -828,7 +851,15 @@ class Session(
threadId: String,
messageId: String,
) {
vault.savePinnedMessage(threadId, messageId.takeIf { it.isNotBlank() })
val mid = messageId.takeIf { it.isNotBlank() }
// A pin on an off-the-record thread went to the vault regardless, a
// record on disk naming the thread for a conversation whose promise is
// that nothing reaches disk. It stays in memory with the thread now.
if (vault.offTheRecord(threadId)) {
if (mid == null) ephemeralPins.remove(threadId) else ephemeralPins[threadId] = mid
} else {
vault.savePinnedMessage(threadId, mid)
}
}
/**
@@ -0,0 +1,116 @@
package io.flamenet.messenger.app
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockWebServer
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Off the record: edits, reactions and pins work, and none of them reach disk.
*
* `record()` and `history()` knew an off-the-record thread lives in memory.
* `applyEdit` and `applyReaction` did not: they read `vault.history`, found
* nothing there for such a thread, and returned false, so an edit or a
* reaction to an off-the-record message was silently dropped. `applyPin`
* went the other way and wrote the pin to the vault regardless, a record on
* disk naming the thread for a conversation whose promise is that nothing
* reaches disk.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class OffTheRecordEditsAndReactionsTest {
private lateinit var server: MockWebServer
private lateinit var vault: Vault
@BeforeTest fun start() {
server = MockWebServer().also { it.start() }
}
@AfterTest fun stop() = server.shutdown()
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<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 val peer = 99L
private val thread = Vault.threadForUser(peer)
private fun session(): Session {
val identity = DeviceIdentity.create()
vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
vault.saveOffTheRecord(thread, on = true)
val base = server.url("/").toString()
return Session(
handle = "alice",
userId = 1,
identity = identity,
vault = vault,
relay = RelayClient(base, "alice", "app pass word"),
api = MessengerApi(base, "alice", "app pass word"),
)
}
@Test
fun `a reaction to an off-the-record message applies, and nothing reaches the vault`() {
val s = session()
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
val applied = s.applyReaction(thread, "m-1", peer, "❤️")
assertTrue(applied, "the reaction was dropped: it looked in the vault for a thread held in memory")
assertTrue(s.history(thread).single().reactions.isNotEmpty())
assertEquals(0, vault.history(thread).length(), "an off-the-record line reached the vault")
}
@Test
fun `an edit to an off-the-record message applies`() {
val s = session()
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
val applied = s.applyEdit(thread, "m-1", peer, "meet at ten")
assertTrue(applied, "the edit was dropped")
assertEquals("meet at ten", s.history(thread).single().text)
assertEquals(0, vault.history(thread).length())
}
@Test
fun `a pin on an off-the-record thread stays in memory`() {
val s = session()
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
s.applyPin(thread, "m-1")
assertEquals("m-1", s.pinnedMessage(thread), "the pin does not show")
assertNull(vault.pinnedMessage(thread), "the pin was written to disk for an off-the-record thread")
}
}