test: prove the catch-up row by row, and say what an old build makes of a page

Phases 8 and 9 of docs/member-archive.md. The tests ran in the phases where the
code they cover first existed -- the way the batch-signing note's did -- so this
is what was missing from them, plus the rollout note, plus the plan marked built.

**Compared row by row, not by count.** The end-to-end test asserted the two
databases held the same *number* of artifacts, chapters and chunks. That is not
the claim: two databases can hold the same counts and disagree about every row,
and a rebuild that lost the group's signature -- or re-authored a row as whoever
sent it -- would pass a count and fail the only thing an archive is for. It now
compares `(id, author, signature)` per row across every archived kind, and then
asserts each one is authored by the room and carries a signature.

The artifact version is the one exception, and it has to be: nobody signs it, it
is derived from the signed artifact on arrival. Which is exactly why it is not
archived, and why a chapter's foreign key survives without it.

**An old build does not ignore an archive page, it renders it.** Phase 9's first
draft said an old build "files it as unsupported, exactly as it does today for
anything it does not know" -- true, and it reads better than it lives. An
unsupported row's content is `event.toJson()` and it renders as an ordinary chat
bubble, so every member on an old build sees each archive page as a raw-JSON
bubble of up to `MAX_PAGE_BYTES`, once per page.

Nothing breaks and nothing is lost, but a group mid-upgrade gets a genuinely
unpleasant transcript, and that is worth knowing before the first archive goes
out. So the rollout rule is stated rather than implied: the receiving half ships
safely on its own -- phases 1-4 send nothing -- and no member starts sending
until every member understands kind 30327. The mitigation if that ever proves
unacceptable is the one the appendix rejects for other reasons, and it is named
there so the trade can be weighed rather than rediscovered.

**The plan is marked built**, with a table of the five places the implementation
chose differently from the plan and why: nine archivable kinds became six, a
count cap that could never fire, queueing moved a phase later, a re-read that was
never needed, and the rollout note above. Phase 8 also records the three tests
that were not in the first draft, each written because something passed for the
wrong reason -- a cap that could not fire, an out-of-order test on an archive
that was never out of order, and a sweep whose "still missing" count included
failures a later pass had already fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 14:37:23 +02:00
parent a315b86918
commit f3984e838c
2 changed files with 151 additions and 42 deletions

View File

@@ -368,6 +368,42 @@ class ArchiveApplyJvmTest {
)
}
/**
* Every archived row as (id, author, signature), sorted.
*
* Counting is not the claim. Two databases can hold the same number of
* artifacts and disagree about every one of them, and a rebuild that lost the
* group's signature -- or re-authored a row as whoever sent it -- would pass a
* count and fail the only thing the archive is for.
*/
private suspend fun rowFingerprints(db: MantraDatabase): List<Triple<String, String, String>> {
val artifacts = db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId)
val versions = artifacts.flatMap {
db.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(it.id)
}
val chapters = versions.flatMap { db.mantraChapterDao().getChaptersByArtifactVersionId(it.id) }
val translationVersions = versions.flatMap {
db.mantraTranslationArtifactVersionDao().getTranslationsByArtifactVersionId(it.id)
}
return buildList {
db.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
artifacts.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
versions.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
chapters.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
chapters.flatMap { db.mantraChunkDao().getChunksByChapterId(it.id) }
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
translationVersions.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
translationVersions
.flatMap {
db.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(it.id)
}
.forEach { add(Triple(it.id, it.publicKey, it.signature)) }
}.sortedBy { it.first }
}
@Test
fun `a member who was never there ends up holding what the group signed`() = runBlocking {
seedSenderWork()
@@ -386,14 +422,26 @@ class ArchiveApplyJvmTest {
assertEquals(rowCounts(sender), rowCounts(receiver))
// Not merely the same shape: the same rows, with the group's signature on
// them. An archive that produced look-alike rows authored by the sender
// would pass a count and fail the only thing that matters.
val theirs = receiver.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).single()
val ours = sender.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).single()
assertEquals(ours.id, theirs.id)
assertEquals(ours.signature, theirs.signature)
assertEquals(chatRoomId, theirs.publicKey)
// Not merely the same shape: the same rows, each with the group's own
// signature on it. An archive that produced look-alike rows authored by
// the member who sent them would pass a count and fail the only thing
// this is for.
val fingerprints = rowFingerprints(receiver)
assertEquals(rowFingerprints(sender), fingerprints)
// And every one of them is the room's own work rather than anybody's.
// The artifact version is the exception and has to be: nobody signs it,
// it is derived from the signed artifact on arrival, which is exactly why
// it is not archived and why a chapter's foreign key survives anyway.
val versionIds = receiver.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId)
.flatMap { receiver.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(it.id) }
.map { it.id }
.toSet()
fingerprints.filterNot { it.first in versionIds }.forEach { (id, author, signature) ->
assertEquals(chatRoomId, author, "row $id is not authored by the room")
assertTrue(signature.isNotBlank(), "row $id came across without a signature")
}
}
@Test