Recognise a forwarded message (§6.16)

An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.

⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so  is part of the test for
whether an envelope is needed, not just a field inside it.

Codec only. Originating a forward needs the transcript work in #10.

Refs paulhitt/flamenet-messenger-ios#28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:50:17 -04:00
co-authored by Claude Opus 4.8
parent 00c043918f
commit 01e6d6fe6d
3 changed files with 130 additions and 3 deletions
@@ -175,6 +175,22 @@ data class Content(
raw?.optBoolean("ctl", false) == true ||
(raw?.optInt("ctl", 0) ?: 0) != 0
/**
* True when 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.
*
* ⚠️ Both shapes accepted, for the same reason [isControl] accepts both:
* `org.json`'s `optBoolean` does not coerce a number, so reading only that
* would miss every `"fwd":1` the other engines send.
*/
val isForwarded: Boolean
get() = raw?.optBoolean("fwd", false) == true ||
(raw?.optInt("fwd", 0) ?: 0) != 0
/**
* A kind this build does not know, which is not control traffic.
*
@@ -309,10 +325,16 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
link: LinkPreview? = null,
forwarded: Boolean = false,
): String =
if (messageId == null && about == null && link == null) body
// ⚠️ `forwarded` belongs in this condition, not just in the
// envelope. A plain message with nothing else to carry goes out as
// raw bytes with no envelope at all — so a forward that was not
// counted here would lose its flag and arrive looking like the
// sender's own words.
if (messageId == null && about == null && link == null && !forwarded) body
else assemble("text", caption = body, messageId = messageId, about = about,
link = link)
link = link, forwarded = forwarded)
fun groupText(
body: String,
@@ -320,8 +342,10 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
groupName: String? = null,
forwarded: Boolean = false,
): String = assemble("text", caption = body, groupId = groupId,
messageId = messageId, about = about, groupName = groupName)
messageId = messageId, about = about, groupName = groupName,
forwarded = forwarded)
fun attachment(
ref: Attachment.Ref,
@@ -332,6 +356,7 @@ data class Content(
/** A live stream this story embeds (§6.13); [ref] is then a poster. */
streamUrl: String? = null,
groupName: String? = null,
forwarded: Boolean = false,
): String = assemble(
kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() },
@@ -342,6 +367,7 @@ data class Content(
about = about,
streamUrl = streamUrl,
groupName = groupName,
forwarded = forwarded,
)
/**
@@ -402,8 +428,12 @@ data class Content(
link: LinkPreview? = null,
streamUrl: String? = null,
groupName: String? = null,
forwarded: Boolean = false,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
// envelope, and what the other two engines read first.
if (forwarded) parts += "\"fwd\":1"
messageId?.let { parts += "\"mid\":${quote(it)}" }
about?.let { parts += "\"re\":${it.toJson()}" }
caption?.let { parts += "\"caption\":${quote(it)}" }
@@ -0,0 +1,96 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Passing a message on without passing on where it came from (§6.16).
*
* ⚠️ **The codec half.** The Android app cannot forward yet — that needs the
* transcript work in #10 — but it must already understand an envelope the
* iPhone app sends, so a forwarded message arrives as a message and not as
* something the reader is told nothing about.
*/
class ForwardWithoutProvenanceTest {
@Test
fun `a forwarded message is marked as forwarded`() {
val content = Content.parse(Content.text("meet at 6", forwarded = true))
assertTrue(content.isForwarded)
assertEquals("meet at 6", content.text)
}
@Test
fun `an ordinary message is not marked forwarded`() {
assertFalse(Content.parse(Content.text("meet at 6")).isForwarded)
}
/**
* 🔴 The whole point: the envelope says THAT it was forwarded and nothing
* about who wrote it or what it answered.
*/
@Test
fun `the envelope carries no author and no reply chain`() {
val wire = Content.text("meet at 6", forwarded = true)
assertFalse(wire.contains("author"), "the envelope carries an author: $wire")
assertFalse(wire.contains("\"re\""), "the envelope carries a reply chain: $wire")
assertNull(Content.parse(wire).about)
}
/**
* ⚠️ `1`, not `true` — §6.10's shape. `org.json`'s optBoolean does not
* coerce a number, so an engine that read only one shape would silently
* miss every forward the other two engines send.
*/
@Test
fun `both flag shapes are accepted`() {
val asNumber = Content.text("hi", forwarded = true)
assertTrue(asNumber.contains("\"fwd\":1"), "wrote the wrong shape: $asNumber")
assertTrue(Content.parse(asNumber).isForwarded)
val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true")
assertTrue(Content.parse(asBool).isForwarded,
"an engine writing `true` would have its forwards read as ordinary messages")
}
/** ⚠️ A forward is still a message: it renders, unlike control traffic. */
@Test
fun `a forwarded message is still renderable`() {
assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable)
}
/** The flag rides an attachment too — a forwarded photo is still a forward. */
@Test
fun `a forwarded attachment keeps the flag`() {
val ref = Attachment.Ref(
kind = Attachment.IMAGE, id = "a".repeat(64),
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
bytes = 1024, mime = "image/jpeg", width = 100, height = 100,
duration = 0.0, expires = 0.0,
)
val content = Content.parse(
Content.attachment(ref, caption = "look", forwarded = true))
assertTrue(content.isForwarded)
assertEquals("look", content.text)
}
/** ⚠️ `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,"))
}
/**
* A missing flag is not a forward — the ordinary case, and the one that
* every message sent before today takes.
*/
@Test
fun `an envelope with no flag is not a forward`() {
val json = JSONObject("""{"fnc":1,"kind":"text","caption":"hi"}""")
assertFalse(Content.parse(json.toString()).isForwarded)
}
}