Off-the-record conversations: the mechanism

A thread held in memory and written to disk by neither side. Close the
app and it is gone: no file to seize, no backup to leak.

It is a proposal, not a switch. The device turning it on sends the
control envelope and keeps writing until the peer echoes it back. A build
that does not know the kind never echoes, so the thread stays on the
record on both sides rather than on one of them, which is worse than not
having the feature because both people would believe the first thing.

Every message reaches disk through one call, so one gate is enough, and a
test pins that count so a second write path cannot quietly walk around
it. Ratchet state is still persisted: messages stop decrypting without
it, and it is not message content.

The control for it comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 21:31:24 -04:00
co-authored by Claude Opus 5
parent 740e8411cb
commit ce7f50230b
6 changed files with 243 additions and 6 deletions
@@ -90,8 +90,14 @@ class Session(
val id: String,
)
/**
* Off-the-record messages, held here and nowhere else. They go when the
* process does, which is the feature rather than a defect.
*/
private val ephemeral = mutableMapOf<String, org.json.JSONArray>()
fun history(threadId: String): List<Line> {
val stored = vault.history(threadId)
val stored = ephemeral[threadId] ?: vault.history(threadId)
return (0 until stored.length()).map {
val m = stored.getJSONObject(it)
Line(
@@ -153,8 +159,7 @@ class Session(
*/
forwarded: Boolean = false,
) {
vault.append(
threadId,
val row =
JSONObject()
.put("author", author)
.also { if (messageId != null) it.put("mid", messageId) }
@@ -172,8 +177,16 @@ class Session(
.also { if (link != null) it.put("lnk", link.toJson()) }
// Written only when true, for the same reason `secs` is.
.also { if (forwarded) it.put("fwd", true) }
.also { if (about != null) it.put("re", about.toJson()) },
)
.also { if (about != null) it.put("re", about.toJson()) }
// Off the record: the row is held in memory and nothing reaches disk.
// This is the one place a message is ever written, which is what makes
// a single gate enough.
if (vault.offTheRecord(threadId)) {
ephemeral.getOrPut(threadId) { org.json.JSONArray() }.put(row)
} else {
vault.append(threadId, row)
}
// A reply arriving cancels the reminder, and it is done HERE because
// this is the one place every inbound message passes through.
@@ -181,7 +194,19 @@ class Session(
}
/** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
fun clearHistory(threadId: String) {
ephemeral.remove(threadId)
vault.forgetHistory(threadId)
}
/** Whether this conversation is off the record. */
fun offTheRecord(peerUserId: Long): Boolean = conversations.offTheRecord(peerUserId)
/** Propose it to the other side; it takes effect when they echo. */
fun proposeOffTheRecord(
peerUserId: Long,
on: Boolean,
) = conversations.proposeOffTheRecord(peerUserId, on)
// -- Scheduled send
// -------------------------------------------------------.
@@ -0,0 +1,88 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* A conversation held in memory and written to disk by neither side.
*
* `Session` needs a vault and a relay, so this reads the source the way
* `SecondDeviceCallTest` does.
*/
class OffTheRecordTest {
private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText()
private val session: String by lazy {
read(
"src/main/kotlin/io/flamenet/messenger/app/Session.kt",
"app/src/main/kotlin/io/flamenet/messenger/app/Session.kt",
)
}
/**
* The whole design rests on there being one way onto disk. If a second
* appears, this fails and whoever added it has to gate it too.
*/
@Test
fun `a message reaches disk through exactly one call`() {
assertEquals(
1,
Regex("vault\\.append\\(").findAll(session).count(),
"more than one write path means the off-the-record gate can be walked around",
)
}
@Test
fun `an off-the-record message is held in memory instead of written`() {
val start = session.indexOf("if (vault.offTheRecord(threadId))")
assertTrue(start >= 0, "record() does not check the setting")
val branch = session.substring(start, minOf(session.length, start + 400))
assertTrue(branch.contains("ephemeral.getOrPut(threadId)"))
assertTrue(branch.contains("vault.append(threadId, row)"), "the ordinary path still writes")
}
@Test
fun `the conversation still reads back while the app is open`() {
assertTrue(session.contains("ephemeral[threadId] ?: vault.history(threadId)"))
}
@Test
fun `clearing a thread drops what was held in memory too`() {
val start = session.indexOf("fun clearHistory(")
assertTrue(start >= 0)
assertTrue(session.substring(start, start + 200).contains("ephemeral.remove(threadId)"))
}
@Test
fun `proposing it saves nothing until the other side answers`() {
val conv =
read(
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
)
val start = conv.indexOf("fun proposeOffTheRecord(")
assertTrue(start >= 0, "no proposal path")
val body = conv.substring(start, conv.indexOf("\n /**", start))
assertTrue(body.contains("sendPlaintext(toUserId, Content.offTheRecord(on))"))
assertTrue(
!body.contains("saveOffTheRecord"),
"the proposer must not turn it on before the peer has echoed",
)
}
@Test
fun `an arriving proposal is applied and echoed once`() {
val conv =
read(
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
)
val start = conv.indexOf("Content.Kind.OFF_RECORD ->")
assertTrue(start >= 0, "nothing handles it on the way in")
val branch = conv.substring(start, minOf(conv.length, start + 600))
assertTrue(branch.contains("vault.saveOffTheRecord"))
assertTrue(branch.contains("!Content.offTheRecordIsEcho(content)"), "an echo must not be echoed")
}
}