The triage pair: snooze, and "remind me if no reply"

Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on — and both are local to this device on purpose. "I am
ignoring this person until Tuesday" and "remind me if they have not answered"
are statements about a relationship, and the relay has no business holding
either. The honest cost is that neither follows you to a new phone; the
alternative is handing a server the list of people you are avoiding.

🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`**
— the one place every inbound message passes through. Hanging it off a screen or
a poll handler means the one path that forgot to call it produces a reminder
that fires after they answered, and that is the single thing that would teach
people to ignore reminders.

The service fires due reminders from its existing loop, deliberately AFTER
`receive()`: a reply that has just landed cancels its own reminder before it can
go off. Checking first would nudge about a conversation answered a moment ago.
Cleared as it fires, too — a reminder is one-shot, and leaving the row would
re-notify every half second for ever.

⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock
passing and nothing runs at that moment, so a stored boolean leaves a
conversation muted past its own deadline.

⚠️ Same privacy rule as message notifications: a count, never a name. Who you
are waiting on is as sensitive as what they said.

engine 163, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 12:18:21 -04:00
co-authored by Claude Opus 4.8
parent 020eba2228
commit 3f0dfd669c
6 changed files with 373 additions and 0 deletions
@@ -0,0 +1,110 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Snooze and reply reminders, at the vault.
*
* ⚠️ **Both live here and nowhere else.** "I am ignoring this person until
* Tuesday" and "remind me if they have not answered" are statements about a
* relationship, and a server has no business holding either. The honest cost is
* that neither follows you to a new device — the alternative is telling the
* relay who you are avoiding.
*/
class AttentionStoreTest {
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 vault() = Vault(
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private val now = 1_700_000_000_000L
// -- Snooze --------------------------------------------------------------
@Test
fun `a thread is not snoozed by default`() {
assertEquals(0L, vault().snoozedUntil("t"))
assertTrue(vault().snoozedThreads(now).isEmpty())
}
@Test
fun `a snoozed thread is listed until its time runs out`() {
val v = vault()
v.saveSnoozedUntil("t", now + 60_000)
assertTrue("t" in v.snoozedThreads(now))
// ⚠️ Expiry is read at query time, not swept. A snooze that outlived
// its own deadline because nothing ran is a conversation the user
// silently stopped being told about.
assertFalse("t" in v.snoozedThreads(now + 120_000))
}
@Test
fun `un-snoozing forgets it rather than storing a zero`() {
val v = vault()
v.saveSnoozedUntil("t", now + 60_000)
v.saveSnoozedUntil("t", 0)
assertEquals(0L, v.snoozedUntil("t"))
assertTrue(v.snoozedThreads(now).isEmpty())
}
@Test
fun `snooze is per conversation`() {
val v = vault()
v.saveSnoozedUntil("quiet", now + 60_000)
assertEquals(setOf("quiet"), v.snoozedThreads(now))
assertEquals(0L, v.snoozedUntil("loud"))
}
// -- Reply reminders -----------------------------------------------------
@Test
fun `a reminder is due only once its time has passed`() {
val v = vault()
v.saveReplyReminder("t", now + 60_000)
assertTrue(v.dueReplyReminders(now).isEmpty())
assertEquals(setOf("t"), v.dueReplyReminders(now + 120_000).keys)
}
/**
* 🔴 A reminder must be clearable by a reply ARRIVING, not only by firing.
* "Remind me if they have not answered" that still fires after they did is
* worse than none — it teaches people to ignore the reminder.
*/
@Test
fun `a reminder can be cancelled before it fires`() {
val v = vault()
v.saveReplyReminder("t", now + 60_000)
v.saveReplyReminder("t", 0)
assertTrue(v.dueReplyReminders(now + 120_000).isEmpty())
}
@Test
fun `reminders are independent per conversation`() {
val v = vault()
v.saveReplyReminder("a", now - 1_000)
v.saveReplyReminder("b", now + 60_000)
assertEquals(setOf("a"), v.dueReplyReminders(now).keys)
}
/** The two stores must not read each other's rows. */
@Test
fun `snooze and reminders do not collide`() {
val v = vault()
v.saveSnoozedUntil("t", now + 60_000)
assertEquals(0L, v.replyReminder("t"))
v.saveReplyReminder("t", now + 30_000)
assertEquals(now + 60_000, v.snoozedUntil("t"))
assertEquals(now + 30_000, v.replyReminder("t"))
}
}