Group sends keep the outbox promise, with a real completion rule

"Undelivered - will send" on a group bubble was a lie: group text and group
stickers recorded PENDING when members were unreachable and nothing queued
them. The 1:1 outbox could not be stretched over it - a group send fails per
member - so the entry grew the thread it restates, and the design questions got
answers instead of a shrug:

WHO is queued: exactly the members nothing reached. Group text reads them out
of the failure map, where the no-device guard files them as "user:<id>"; the
sticker loop sees per-member results directly. A member whose devices took the
message is NOT queued - resending to them would duplicate it.

WHERE it restates: the group thread, carried on the entry. Restating
threadForUser(member) would touch a 1:1 thread that may not even exist.

WHEN the bubble flips: only when the LAST queued member is reached. Flipping on
the first success would mark the message delivered while other members are
still owed it - "will send" would quietly stop being true for exactly the
people it was still true for. Until the set drains, PENDING remains the honest
label, because the queue genuinely will keep sending.

The test walks the whole rule: two members away, one returns (bubble still
PENDING, one entry left), the second returns (bubble DELIVERED, group thread
redrawn, the prose body not replaced by the wire form).

⚠️ iOS sends to groups too (sendGroupText/sendGroupImage) and still has no
group outbox - it stays on the roadmap with this as the reference
implementation.

Engine, client and app suites green; seven outbox tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 10:21:07 -04:00
co-authored by Claude Opus 5
parent 09a4fb90fc
commit 59fa88c2ad
2 changed files with 116 additions and 7 deletions
@@ -257,13 +257,20 @@ class Session(
Conversations.Sent {
var delivered = 0
var failed = 0
val at = System.currentTimeMillis()
val wire = io.flamenet.e2e.Content.sticker(pack, stickerId, groupId = group.id)
for (member in group.memberIds) {
if (member == userId) continue
val one = conversations.sendSticker(member, pack, stickerId, groupId = group.id)
delivered += one.delivered.size
failed += one.failed.size
// The same rule as group text: queue exactly the members nothing
// reached, with the group thread to restate.
if (!one.reachedSomeone) {
enqueue(member, wire, at, thread = Vault.threadForGroup(group.id))
}
}
record(Vault.threadForGroup(group.id), "", mine = true, author = userId,
record(Vault.threadForGroup(group.id), "", mine = true, author = userId, at = at,
sticker = Sticker(pack, stickerId),
delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING)
return Conversations.Sent(
@@ -343,10 +350,15 @@ class Session(
* which transmits its argument as the plaintext verbatim — so both kinds
* take the same path out.
*/
private fun enqueue(peerUserId: Long, text: String, at: Long) {
private fun enqueue(peerUserId: Long, text: String, at: Long,
thread: String? = null) {
val pending = vault.outbox()
pending.put(JSONObject()
.put("to", peerUserId).put("text", text).put("at", at))
.put("to", peerUserId).put("text", text).put("at", at)
// ⚠️ Present only for group entries: the bubble to restate lives in
// the GROUP thread, not in a 1:1 thread with this member — which
// may not even exist.
.also { if (thread != null) it.put("thread", thread) })
vault.saveOutbox(pending)
}
@@ -366,23 +378,50 @@ class Session(
val touched = mutableSetOf<String>()
val stillWaiting = org.json.JSONArray()
// Group entries that delivered this pass, keyed "thread|at". The bubble
// only flips when the LAST member of its set is reached — see below.
val groupDelivered = mutableSetOf<Pair<String, Long>>()
for (i in 0 until pending.length()) {
val item = pending.optJSONObject(i) ?: continue
val to = item.optLong("to")
val text = item.optString("text")
val at = item.optLong("at")
val threadId = Vault.threadForUser(to)
val groupThread = item.optString("thread").takeIf { it.isNotBlank() }
val sent = runCatching { conversations.send(to, text) }.getOrNull()
if (sent?.reachedSomeone == true) {
restate(threadId, at, Delivery.DELIVERED)
touched += threadId
if (groupThread == null) {
restate(Vault.threadForUser(to), at, Delivery.DELIVERED)
touched += Vault.threadForUser(to)
} else {
groupDelivered += groupThread to at
}
} else {
stillWaiting.put(item)
}
}
vault.saveOutbox(stillWaiting)
// 🔴 **A group bubble flips only when nobody is left waiting on it.**
// One message fans out to many members and each gets its own entry;
// restating on the first success would mark it delivered while other
// members are still owed it, and "will send" would quietly stop being
// true for them. Until the set drains, PENDING remains the honest
// label — the queue genuinely will keep sending.
for ((thread, at) in groupDelivered) {
var remaining = false
for (i in 0 until stillWaiting.length()) {
val item = stillWaiting.optJSONObject(i) ?: continue
if (item.optString("thread") == thread && item.optLong("at") == at) {
remaining = true; break
}
}
if (!remaining) {
restate(thread, at, Delivery.DELIVERED)
touched += thread
}
}
return touched
}
@@ -446,8 +485,20 @@ class Session(
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId)
record(Vault.threadForGroup(group.id), text, mine = true, author = userId,
val at = System.currentTimeMillis()
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 **The group half of the outbox promise.** Members with no
// registered device are filed in the failure map as "user:<id>" by the
// no-device guard; each gets an entry carrying the SAME group body and
// the group thread to restate. A member whose devices took it is not
// queued — resending to them would duplicate the message.
val body = io.flamenet.e2e.Content.groupText(text, group.id)
sent.failed.keys.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.forEach { member ->
enqueue(member, body, at, thread = Vault.threadForGroup(group.id))
}
return sent
}