Check the content envelope against envelopes the iPhone emitted

The content layer had no vectors. Everything from section 6.6 onward was
guarded only by same-code round trips: this engine encodes and parses
through its own codec and would pass unchanged if it and the iPhone
disagreed about every key. That is how SPEC 6.18 specified `ment` for as
long as it has existed while both clients have always sent `men`.

`content-v1.json` holds fifteen envelopes generated by RUNNING the
iPhone's encoders, and this suite parses each one and asserts field by
field. "It did not crash" is what the old tests already proved.

One of them pins something easy to get wrong twice: the iPhone writes
`fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a
number. A parser reading only the boolean answers false and the message
quietly loses the fact that it was passed on.

The suite also fails if the vector file grows a case nobody asserts on.

Separately, and the reason any of this is trustworthy: the test task did
not track the vector files at all. They arrive by environment variable,
Gradle does not track one as an input, so a vector could change under a
build directory that already had results and the task reported
UP-TO-DATE without running. That is not hypothetical here, it is how
twenty vector tests once passed in CI for weeks without executing, on
this same runner, whose build directory survives between runs. All six
vector files are now declared inputs.

Proven rather than asserted: with the inputs declared, flipping `men` to
`ment` in one vector turns the suite red without `--rerun-tasks`. Before
the fix the same change left it UP-TO-DATE and green.

Full suite: 1294 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 07:20:17 -04:00
co-authored by Claude Opus 5
parent 5d84ad180c
commit 19add570cb
4 changed files with 238 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
d8e1826a946fe5a1f956a3bb42dcb55969e3a890 588929cca79e252c62fb8d50a77218bb4a6f884a
+4 -1
View File
@@ -62,12 +62,14 @@ unit-tests:
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json"
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json"
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json"
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json"
# Checked before the suite runs. A path that silently does not exist puts # Checked before the suite runs. A path that silently does not exist puts
# the tests straight back where they were, and the failure would read as a # the tests straight back where they were, and the failure would read as a
# protocol bug rather than a missing file. # protocol bug rather than a missing file.
- | - |
for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \ for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \
"$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS"; do "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \
"$FLAMENET_CONTENT_VECTORS"; do
[ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; } [ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; }
done done
# Clear old results before running, or the gate counts ghosts. The # Clear old results before running, or the gate counts ghosts. The
@@ -160,6 +162,7 @@ vectors-head:
- export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json"
- export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json"
- export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json"
- export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json"
# Only the module that reads vectors. The rest of the suite has nothing # Only the module that reads vectors. The rest of the suite has nothing
# to say about whether flamenet-e2e moved, and running it here would double # to say about whether flamenet-e2e moved, and running it here would double
# the board's cost to answer a question it cannot answer. # the board's cost to answer a question it cannot answer.
+30
View File
@@ -58,6 +58,36 @@ tasks.test {
events("passed", "failed", "skipped") events("passed", "failed", "skipped")
showStandardStreams = true showStandardStreams = true
} }
// The vector files arrive by environment variable, and Gradle does not
// track an environment variable as an input. So a vector could move under
// a build directory that already had results in it and the task would
// report UP-TO-DATE without running a thing.
//
// That is not hypothetical. Twenty vector tests once "passed" in CI for
// weeks without ever executing, because the runner is a developer machine
// whose build directory survives between runs, and a replayed local pass
// looks exactly like a real one in the log.
//
// Declaring each file as an input is what makes a changed vector
// invalidate the task. Proven by changing one key in one vector and
// watching the suite go red without `--rerun-tasks`.
listOf(
"FLAMENET_VECTORS",
"FLAMENET_PQ_VECTORS",
"FLAMENET_ACCOUNT_VECTORS",
"FLAMENET_BACKUP_VECTORS",
"FLAMENET_BACKUP_PAYLOAD_VECTORS",
"FLAMENET_CONTENT_VECTORS",
).forEach { name ->
val path = System.getenv(name)
if (path != null && file(path).exists()) {
inputs
.file(path)
.withPropertyName("vector-$name")
.withPathSensitivity(PathSensitivity.NONE)
}
}
} }
// Coverage for this module. `:engine` and `:client` are plain JVM libraries // Coverage for this module. `:engine` and `:client` are plain JVM libraries
@@ -0,0 +1,203 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import java.io.File as JFile
/**
* This engine against envelopes the iPhone actually emitted.
*
* The content layer has been guarded only by same-code round trips: this engine
* encodes and parses through its own codec, and would pass unchanged if it and
* the iPhone disagreed about every key. The property those tests are believed
* to establish is cross-platform agreement, and they cannot fail on it. That is
* how SPEC 6.18 came to specify `ment` while both clients have always sent
* `men`, and how `ctl:true` cost a whole envelope on one platform only.
*
* These envelopes were generated by running the iPhone's encoders, not by
* reading them. Parsing is asserted field by field, because "it did not crash"
* is what the old tests already proved.
*/
class ContentVectorTest {
private val vectors: JSONObject by lazy {
val candidates =
listOfNotNull(
System.getenv("FLAMENET_CONTENT_VECTORS"),
"../../engine/test/vectors/content-v1.json",
"../../../flamenet-e2e/test/vectors/content-v1.json",
"../../flamenet-e2e/test/vectors/content-v1.json",
).map { JFile(it) }
val found =
candidates.firstOrNull { it.exists() }
?: throw AssertionError(
"content vectors not found. Looked in: " +
candidates.joinToString(", ") { it.absolutePath },
)
JSONObject(found.readText())
}
private fun case(name: String): Content {
val cases = vectors.getJSONArray("cases")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
if (c.getString("name") == name) return Content.parse(c.getString("envelope"))
}
throw AssertionError("no vector case named $name")
}
@Test
fun `a plain text message parses as text`() {
val c = case("text")
assertEquals(Content.Kind.TEXT, c.kind)
assertEquals("still on for saturday", c.text)
assertEquals("m-1", c.messageId)
}
@Test
fun `everything a text envelope can carry survives at once`() {
val c = case("text-reply-link-forwarded-ttl")
assertEquals("passing this on", c.text)
assertEquals("m-0", c.about?.messageId)
assertEquals(9L, c.about?.author)
assertEquals("https://example.org", c.link?.url)
assertEquals(3600, c.lifetimeSeconds)
// The iPhone writes `fwd` as the NUMBER 1, not as true. `optBoolean`
// does not coerce a number, so a parser reading only that answers false
// and the message silently loses the fact that it was passed on.
assertTrue(c.isForwarded, "fwd arrives as 1, not true")
}
@Test
fun `mentions arrive under men, which is not what the spec says`() {
val c = case("group-text-mentions")
// SPEC 6.18 specifies `ment`. Both shipped clients send `men`. The
// clients agree; the spec is the outlier. This vector is what makes
// that checkable rather than a matter of whose reading you trust.
assertEquals(listOf(12, 34), c.mentions)
assertEquals("gid-1", c.groupId)
}
@Test
fun `a reaction never becomes a bubble`() {
val c = case("react")
assertEquals(Content.Kind.REACT, c.kind)
assertEquals("🔥", c.reaction)
assertEquals("", c.text, "a reaction with text would render as an empty message")
assertEquals("m-0", c.about?.messageId)
}
@Test
fun `a story reaction and a story reply both say they are about a story`() {
assertTrue(case("react-to-story").reactsToStory)
assertTrue(case("story-reply").repliesToStory)
assertEquals("s-1", case("story-reply").about?.messageId)
}
@Test
fun `an edit names the message it replaces`() {
val c = case("edit")
assertEquals(Content.Kind.EDIT, c.kind)
assertEquals("m-1", c.editOf)
assertEquals("fixed typo", c.text)
}
@Test
fun `a pin names its message and carries no caption`() {
val c = case("pin")
assertEquals(Content.Kind.PIN, c.kind)
assertEquals("m-1", c.pins)
}
@Test
fun `a sticker is a sticker and not empty text`() {
val c = case("sticker")
assertEquals(Content.Kind.STICKER, c.kind)
assertEquals("classic", c.sticker?.pack)
assertEquals("wave", c.sticker?.id)
assertEquals("", c.text)
}
@Test
fun `a story made of words keeps its expiry`() {
val c = case("text-story")
assertEquals("hello from the hill", c.text)
assertEquals(1_700_600_000.0, c.textStoryExpires)
}
@Test
fun `an image attachment arrives whole`() {
val c = case("image")
val a = assertNotNull(c.attachment)
assertEquals("att-1", a.id)
assertEquals(4096, a.bytes)
assertEquals(800, a.width)
assertEquals(600, a.height)
assertEquals(32, a.key.size)
assertEquals(32, a.digest.size)
assertEquals("look", c.text)
}
@Test
fun `a voice note keeps its duration, which is its whole interface`() {
val a = assertNotNull(case("audio").attachment)
assertEquals(12.5, a.duration)
assertEquals("audio/mp4", a.mime)
}
@Test
fun `a document keeps the name and label it was filed under`() {
val c = case("file")
val a = assertNotNull(c.attachment)
// `fn`, `cls` and `cmp` are top-level envelope keys, not part of `att`,
// and none of the three appears anywhere in SPEC.md.
assertEquals("contract.pdf", a.filename)
assertEquals(3, a.classification)
assertEquals(listOf(2, 7), a.compartments)
}
@Test
fun `a story in a set knows which one it is`() {
val c = case("story-item")
val set = assertNotNull(c.storySet)
assertEquals("set-1", set.id)
assertEquals(1, set.i)
assertEquals(3, set.n)
}
@Test
fun `an avatar announcement is recognised as one`() {
val c = case("avatar")
assertEquals("avatar", c.kind)
assertEquals("att-1", assertNotNull(c.attachment).id)
}
@Test
fun `every vector in the file is covered by a test here`() {
// A vector nobody asserts on is a vector that proves nothing. This
// fails when somebody adds a case and no assertion to go with it.
val cases = vectors.getJSONArray("cases")
val named = (0 until cases.length()).map { cases.getJSONObject(it).getString("name") }.toSet()
val asserted =
setOf(
"text",
"text-reply-link-forwarded-ttl",
"group-text-mentions",
"react",
"react-to-story",
"story-reply",
"edit",
"pin",
"sticker",
"text-story",
"image",
"audio",
"file",
"story-item",
"avatar",
)
assertEquals(emptySet(), named - asserted, "vectors with no assertion in this file")
}
}