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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user