Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)

🔴 This was an interop bug, not a missing feature. The engine has parsed
`editOf`, `pins` and `fwd` for as long as they have existed, and it exposes
`isRenderable` — "does this belong in a thread" — as the single answer.
`Session`'s inbound loop never consulted it.

So three things arrived from the iPhone app and were filed as ordinary bubbles.
An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the
typo it was meant to replace — a correction that made the thread worse than the
mistake did. A pin carries only an id, so it drew as a blank bubble. And a
forward was drawn as the sender's own words.

Worst of the three is the one nobody had hit yet: a control envelope from a
NEWER peer carries `ctl` and a kind this build has never heard of, and would
have drawn a bubble full of JSON in somebody's conversation. That is precisely
the failure `isControl` was written to prevent, defeated by nobody calling it —
and it would have shipped to every older Android install the first time a
control kind was added on iOS. The guard is `isRenderable` rather than a list of
kinds this build knows, because an allowlist is only correct until the other
platform adds one.

What is applied now:

- an edit rewrites the message it names and stamps `edited`, so a bubble can
  say so — silently replacing the text is worse than the typo, because the
  reader cannot tell a correction from their own misremembering;
- a pin is stored per thread, sealed in the vault like the transcript, as an ID
  and never a copy: a copy goes stale against an edit and outlives a deletion;
- `fwd` reaches the transcript so second-hand words are not drawn as the
  sender's own — THAT it was forwarded, never from whom.

🔴 An inbound edit is applied only when it comes from the person whose message
it is. Without that check any contact sharing a session could rewrite anybody's
words in the reader's transcript — a better attack than sending a message of
their own, because the words would carry somebody else's name.

⚠️ Clearing a thread clears its pin. A banner pointing at a message that was
just cleared reads as the clear not having worked.

⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl`
accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is
how this class of bug shipped once already.

9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡:
the inbound half is done, originating each still needs the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 13:53:13 -04:00
co-authored by Claude Opus 5
parent ab8ff658b2
commit e0420a00c6
4 changed files with 344 additions and 9 deletions
@@ -116,6 +116,21 @@ class Session(
* to know anything had been dropped.
*/
val about: Content.Reference? = null,
/**
* Second-hand words (§6.16).
*
* ⚠️ THAT it was forwarded, never from whom.
*/
val forwarded: Boolean = false,
/**
* When this message was last edited (§6.15), or null if never.
*
* 🔴 **Kept so the bubble can say "edited".** A correction that silently
* replaces the text is worse than the typo: the reader has no way to
* know the words changed after they read them, and no way to tell a
* correction from their own misremembering.
*/
val editedAt: Long? = null,
/**
* A timer this one message carries, in seconds (§6.17); 0 for none.
*
@@ -163,6 +178,12 @@ class Session(
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
// ⚠️ Both shapes, for the same reason `Content.isForwarded`
// accepts both: `org.json`'s `optBoolean` does not coerce a
// number, so reading only that would miss a `"fwd":1` written
// by any other engine.
forwarded = m.optBoolean("fwd", false) || m.optInt("fwd", 0) != 0,
editedAt = m.optLong("edited", 0L).takeIf { it > 0L },
lifetimeSeconds = m.optInt("secs", 0),
)
}
@@ -184,6 +205,15 @@ class Session(
about: Content.Reference? = null,
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
/**
* Whether the sender is passing on somebody else's words (§6.16).
*
* ⚠️ Says THAT it was forwarded and never from whom — the point of a
* forward is that the original author does not travel with it. This
* exists so second-hand words are not shown as the sender's own, which
* is a different thing from attribution.
*/
forwarded: Boolean = false,
) {
vault.append(threadId, JSONObject()
.put("author", author)
@@ -201,10 +231,10 @@ class Session(
}
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
.also { if (link != null) it.put("lnk", link.toJson()) }
// ⚠️ Written only when present. A null on every message would grow
// the transcript for nothing and make "no id" indistinguishable
// from "an id of nothing".
.also { if (messageId != null) it.put("mid", messageId) }
// ⚠️ Written only when true, for the same reason `secs` is. A false
// on every message grows the transcript for a flag that is almost
// always absent.
.also { if (forwarded) it.put("fwd", true) }
.also { if (about != null) it.put("re", about.toJson()) })
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
@@ -627,6 +657,70 @@ class Session(
return hit
}
/**
* Apply an inbound edit to the message it names (§6.15).
*
* 🔴 **An edit is never a bubble of its own.** It changes one that is
* already there, or it does nothing. Android had the codec and no
* application, so an edit from the iPhone app arrived carrying the new
* wording in `text` and was filed as an ORDINARY MESSAGE — a correction
* showing up as a duplicate beside the typo it was meant to replace, which
* is worse than the typo.
*
* 🔴 **Only the author may edit their own message.** Without this check any
* contact sharing a session could rewrite anybody's words in the reader's
* transcript, which is a far better attack than sending a message of their
* own: the words would carry somebody else's name.
*
* Returns whether a message was found and changed.
*/
fun applyEdit(
threadId: String,
messageId: String,
author: Long,
newText: String,
at: Long = System.currentTimeMillis(),
): Boolean {
val stored = vault.history(threadId)
var hit = false
for (i in 0 until stored.length()) {
val row = stored.getJSONObject(i)
if (row.optString("mid") != messageId) continue
// 🔴 The author check. `mine` is this device's own message; an
// inbound edit must come from the person whose message it is.
val wroteIt = if (row.optBoolean("mine")) author == userId
else row.optLong("author") == author
if (!wroteIt) break
row.put("text", newText).put("edited", at)
hit = true
break
}
if (hit) vault.replaceHistory(threadId, stored)
return hit
}
/**
* The message pinned in a thread, or null (§6.19).
*
* ⚠️ An id, never a copy. A stored copy would go stale the moment the
* message was edited, and would survive the message being deleted — a
* banner quoting words that are no longer in the conversation.
*/
fun pinnedMessage(threadId: String): String? =
vault.pinnedMessage(threadId)
/**
* Pin a message, or take the pin down with an empty [messageId].
*
* ⚠️ **The last pin wins and there is only ever one.** A thread with a list
* of pins is a second inbox; §6.19 is one banner, and two people pinning at
* once resolve by whoever's envelope arrived last — the same rule the
* transcript already uses for everything else.
*/
fun applyPin(threadId: String, messageId: String) {
vault.savePinnedMessage(threadId, messageId.takeIf { it.isNotBlank() })
}
/**
* React to somebody's message, or take it back with an empty [emoji].
*
@@ -924,6 +1018,37 @@ class Session(
if (story.isActive(now)) incomingStories += story
continue
}
// 🔴 **An EDIT (§6.15) changes a message; it is never one.**
// Android had the codec and no application, so an edit from the
// iPhone app fell straight through this loop carrying its new
// wording in `text` and was filed as an ordinary bubble — a
// correction arriving as a duplicate beside the typo it was meant
// to replace, which is worse than the typo.
message.content.editOf?.let { editing ->
val gid = if (message.fromAddress != null) null
else message.content.groupId
val thread = gid?.let(Vault::threadForGroup)
?: message.fromAddress?.let(Vault::threadForAddress)
?: Vault.threadForUser(message.fromUser)
if (applyEdit(thread, editing, message.fromUser,
message.content.text.orEmpty())) {
touched += thread
}
continue
}
// 🔴 **A PIN (§6.19) is an id, not a bubble.** Same story: without
// this it was filed as a message whose text was empty, so a pin
// from the other app drew a blank bubble in the conversation.
message.content.pins?.let { pinned ->
val gid = if (message.fromAddress != null) null
else message.content.groupId
val thread = gid?.let(Vault::threadForGroup)
?: message.fromAddress?.let(Vault::threadForAddress)
?: Vault.threadForUser(message.fromUser)
applyPin(thread, pinned)
touched += thread
continue
}
// A reaction to a MESSAGE. Folded onto the message it is about,
// never filed as one of its own — a thread where every thumbs-up
// is a bubble is a thread nobody can read.
@@ -997,6 +1122,19 @@ class Session(
// worse, match by coincidence. Groups do not federate yet, so a
// remote sender's group claim is ignored and the message files in
// their own thread.
// 🔴 **The guard that stops this list needing to be complete.**
// Every branch above names one kind of non-message. `isRenderable`
// is the engine's own answer to "does this belong in a thread", and
// it is false for reactions, call signals, edits, pins and — the
// case that matters most — ANY control envelope from a newer peer,
// which carries `ctl` and a kind this build has never heard of.
//
// Without it, a control kind added on iOS ships a bubble full of
// JSON into somebody's conversation on every Android install that
// predates it. The engine has had `isRenderable` since §6.15; the
// app simply never consulted it.
if (!message.content.isRenderable) continue
val gid = if (message.fromAddress != null) null else message.content.groupId
if (gid != null) {
val known = groupRoster(gid)
@@ -1046,7 +1184,11 @@ class Session(
// side.
messageId = message.content.messageId,
about = message.content.about,
lifetimeSeconds = message.content.lifetimeSeconds ?: 0)
lifetimeSeconds = message.content.lifetimeSeconds ?: 0,
// §6.16 — so second-hand words are not drawn as the sender's
// own. The engine has read `fwd` off the wire since forwarding
// shipped; the transcript was where it stopped.
forwarded = message.content.isForwarded)
touched += thread
}