Disappearing messages, symmetric and from send time (§6.11)

The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.

**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.

**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.

**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.

⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.

⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.

The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.

engine 155, client 94, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 11:23:04 -04:00
co-authored by Claude Opus 4.8
parent c9e386ec0d
commit f9f063a211
8 changed files with 471 additions and 4 deletions
@@ -566,6 +566,31 @@ class Conversations(
return Sent(delivered, failed, sealed = peerKey != null)
}
// -- Disappearing messages (§6.11) ---------------------------------------
/**
* Set this conversation's timer, and tell the other side.
*
* 🔴 **Symmetric by design.** Either party may set it and both delete, so
* the announcement rides the ratchet as ordinary control traffic — the
* relay never learns that a conversation is timed, let alone for how long.
* A timer only one side honoured would be worse than none: the person who
* turned it on would believe their words were gone from a device still
* holding them.
*
* ⚠️ Stored locally **first**. If the send fails, this device still expires
* its own copy — a half-applied timer that deletes too much is recoverable
* by asking again; one that deletes too little is a broken promise.
*/
fun setDisappearing(toUserId: Long, seconds: Int) {
vault.saveDisappearSeconds(Vault.threadForUser(toUserId), seconds)
sendPlaintext(toUserId, Content.disappearing(seconds))
}
/** This conversation's timer in seconds, 0 when off. */
fun disappearSeconds(toUserId: Long): Int =
vault.disappearSeconds(Vault.threadForUser(toUserId))
/**
* Read the inbox, decrypt what we can, and advance the watermark.
*
@@ -795,7 +820,12 @@ class Conversations(
}
/** Take in the control messages this layer owns, rather than showing them. */
private fun consumeControl(fromUser: Long, content: Content) {
/**
* ⚠️ `internal`, not private: this is the seam where a control envelope
* becomes a stored fact, and it is where the wiring is worth asserting
* directly rather than through a full ratchet round trip.
*/
internal fun consumeControl(fromUser: Long, content: Content) {
when (content.kind) {
Content.Kind.DELIVERY_KEY -> {
val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return
@@ -818,6 +848,13 @@ class Conversations(
val raw = content.raw ?: return
trust.gossip(raw.optInt("size"), raw.optString("root"))
}
// The other side set a timer (§6.11). Adopted rather than offered:
// a timer both parties have to agree to separately is a timer that
// is off whenever one of them has not noticed the prompt.
Content.Kind.DISAPPEAR -> {
val seconds = Content.disappearSeconds(content) ?: return
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
}
else -> Unit
}
}
@@ -0,0 +1,78 @@
package io.flamenet.client
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockWebServer
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The seam where a peer's timer announcement becomes a stored fact (§6.11).
*
* 🔴 **Symmetric or worthless.** If only the party who set the timer expires
* their copy, the feature is a lie: they believe their words are gone from a
* phone that is still holding them. So a `ttl` envelope from the other side has
* to be adopted, not merely offered — which is what this asserts.
*/
class DisappearingControlTest {
private lateinit var server: MockWebServer
@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 conversations(): Pair<Conversations, Vault> {
val identity = DeviceIdentity.create()
val vault = Vault(
MapStorage(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
).also { it.saveIdentity(identity) }
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
return Conversations(identity, vault, relay) to vault
}
@Test
fun `a timer the peer set is adopted`() {
val (conversations, vault) = conversations()
conversations.consumeControl(42L, Content.parse(Content.disappearing(86_400)))
assertEquals(86_400, vault.disappearSeconds(Vault.threadForUser(42L)))
assertEquals(86_400, conversations.disappearSeconds(42L))
}
/** Turning it off has to travel too, or it can only ever be tightened. */
@Test
fun `a peer turning the timer off is adopted`() {
val (conversations, vault) = conversations()
conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600)))
conversations.consumeControl(42L, Content.parse(Content.disappearing(0)))
assertEquals(0, vault.disappearSeconds(Vault.threadForUser(42L)))
}
/** ⚠️ Per conversation. One contact's timer must not touch another's. */
@Test
fun `the timer applies only to the conversation it came from`() {
val (conversations, _) = conversations()
conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600)))
assertEquals(3_600, conversations.disappearSeconds(42L))
assertEquals(0, conversations.disappearSeconds(99L))
}
/** The announcement is control traffic and never lands in the thread. */
@Test
fun `the announcement is never shown as a message`() {
val parsed = Content.parse(Content.disappearing(60))
assertEquals(false, parsed.isRenderable)
}
}