diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index 35adb275..28c56e78 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -129,9 +129,19 @@ data class GroupSignedEvent( * * Everything needed is on the row, which is the property worth having: the * author has to be the room, [id] has to be the hash of the fields sitting - * next to it, and [signature] has to verify. A row that fails is a row whose - * columns have drifted from the event they came from -- there is no - * ceremony, key state or derivation path to consult first. + * next to it, and [signature] has to verify -- no ceremony, key state or + * derivation path to consult first. + * + * **False does not always mean the row is corrupt.** The usual reading is + * that its columns have drifted from the event they came from, and that is + * the case worth acting on. But a room that was never derived from its + * group's key -- [derivationPath] null, the legacy shape `signingPath` + * documents -- signs as the bare threshold key rather than as its own id, so + * a perfectly good event in such a room fails here and cannot be made to + * pass: the key it would have to be checked against is not on the row and + * cannot be walked back to from one. Those rooms get an empty archive for + * the same reason, which is a limit of the derivation rather than of this + * check. */ fun verifies(): Boolean = GroupKeyStateEvent.isSignedByRoom(toEvent(), chatRoomId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt index a3659d44..b2c7eddd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt @@ -18,6 +18,7 @@ import kotlin.time.Instant import press.mantra.compose.nostr.archive.ArchiveEvent import press.mantra.compose.nostr.archive.ArchiveRequestEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.nip30303.TranslationChunkEvent /** * Building the group's signed record into pages a member who lacks it can apply. @@ -640,7 +641,72 @@ object ArchiveManager { ) } - return recorded + rebuilt + return currentTranslationsOnly(recorded + rebuilt) + } + + /** + * Drops every translation of a passage but the one that stands. + * + * The record keeps every event the group ever signed, deliberately -- a + * signature is the group's statement and discarding one is not this table's + * business. The rows do not: `ChatMessage.applyInnerEvent` supersedes a + * translation chunk, deleting the one it replaces, so a passage translated + * three times leaves one row and three events. + * + * That difference reaches the archive the moment it is read from the record + * rather than rebuilt from rows, and it compounds: every draft a group ever + * signed would travel in every archive it ever sends, for as long as the room + * exists. An archive exists to catch a member up on where the group has got + * to, not to hand them its drafting history. + * + * **The rule is the applying arm's, restated rather than approximated**: + * newest by the timestamp the group signed at, id breaking a tie. It has to + * be, or the archive would ship one translation as current and the recipient + * would settle on another -- and since both are validly signed, nothing + * downstream would notice the disagreement. + * + * Dropping the drafts is safe precisely because the recipient applies the + * same rule: it is not what keeps them correct, only what stops them being + * sent work they would immediately discard. + * + * A translation naming no passage is left alone rather than grouped with + * others: it is unappliable either way, and letting one stand in for a whole + * passage would let a malformed event suppress a good one. + */ + private fun currentTranslationsOnly(events: List): List { + val translations = events.filter { it.kind == TranslationChunkEvent.KIND } + if (translations.size < 2) return events + + val current = translations + .groupBy { event -> + val translation = TranslationChunkEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + + val chapterId = translation.translationChapterId() + val chunkId = translation.chunkId() + + if (chapterId == null || chunkId == null) event.id else "$chapterId/$chunkId" + } + .values + .mapNotNull { candidates -> + candidates.maxWithOrNull(compareBy({ it.createdAt }, { it.id })) + } + .mapTo(mutableSetOf()) { it.id } + + if (current.size != translations.size) { + logger.d( + "Leaving ${translations.size - current.size} superseded translation(s) " + + "out of the archive" + ) + } + + return events.filter { it.kind != TranslationChunkEvent.KIND || it.id in current } } /** diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt index 77e8e103..2c0439cc 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt @@ -608,4 +608,140 @@ class ArchiveAssemblyJvmTest { idOf(ArchiveManager.assemble(db, chatRoomId, newMember).single()) ) } + + // ---- Supersession ---------------------------------------------------- + // + // The record keeps every event the group signed; the rows keep one + // translation per passage. Reading the archive from the record is what makes + // the difference visible, and these are what keep the two sides agreeing + // about which translation stands. + + /** The passage the seed already translated, resolved back out of the rows. */ + private suspend fun seededPassage(): Pair { + val artifactId = db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).single().id + val versionId = db.mantraArtifactVersionDao() + .getArtifactVersionsByArtifactId(artifactId).single().id + val chapterId = db.mantraChapterDao().getChaptersByArtifactVersionId(versionId).single().id + val chunkId = db.mantraChunkDao().getChunksByChapterId(chapterId).first().id + val translationChapterId = db.mantraTranslationChapterDao() + .getTranslationChaptersByTranslationArtifactVersionId( + db.mantraTranslationArtifactVersionDao() + .getTranslationsByArtifactVersionId(versionId).single().id + ).single().id + + return translationChapterId to chunkId + } + + private suspend fun archivedTranslations(): List = + ArchiveManager.assemble(db, chatRoomId, newMember) + .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } + .filter { it.kind == TranslationChunkEvent.KIND } + + @Test + fun `a retranslated passage archives once, not once per draft`() = runBlocking { + seedSignedWork() + val (translationChapterId, chunkId) = seededPassage() + + val current = apply( + TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = chunkId, + index = 0, + text = "A better rendering.", + createdAt = 1_700_000_070L + ) + ) + recordEverythingApplied() + + // One row, because the applying arm superseded the first. Two events, + // because the record keeps what the group signed and does not pretend a + // signature was never made. + assertEquals( + 1, + db.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapterId).size + ) + assertEquals( + 2, + db.groupSignedEventDao().getByChatRoomIdAndKind(chatRoomId, TranslationChunkEvent.KIND).size + ) + + // And one payload. Otherwise every draft a group ever signed travels in + // every archive it ever sends, for as long as the room exists. + assertEquals(listOf(current.id), archivedTranslations().map { it.id }) + } + + @Test + fun `the archive picks the translation the recipient would keep`() = runBlocking { + seedSignedWork() + val (translationChapterId, chunkId) = seededPassage() + + // Two translations signed in the same second, which the applying arm + // settles by id. The archive has to settle it the same way or it ships + // one as current while the recipient keeps the other -- and both being + // validly signed, nothing downstream would notice they disagree. + val sameSecond = 1_700_000_070L + val rivals = listOf("Rendering one.", "Rendering two.", "Rendering three.") + .map { text -> + apply( + TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = chunkId, + index = 0, + text = text, + createdAt = sameSecond + ) + ) + } + recordEverythingApplied() + + val kept = db.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapterId).single() + + assertEquals(rivals.maxOf { it.id }, kept.id, "the row keeps the highest id of the second") + assertEquals(listOf(kept.id), archivedTranslations().map { it.id }) + } + + @Test + fun `two passages each keep their own current translation`() = runBlocking { + seedSignedWork() + val (translationChapterId, firstChunkId) = seededPassage() + + val chapterId = db.mantraChapterDao() + .getChaptersByArtifactVersionId( + db.mantraArtifactVersionDao() + .getArtifactVersionsByArtifactId( + db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).single().id + ).single().id + ).single().id + val secondChunkId = db.mantraChunkDao().getChunksByChapterId(chapterId) + .first { it.id != firstChunkId }.id + + // Grouping is per passage, so retranslating one must not take the other + // with it -- the bug a naive "newest translation wins" would have. + val retranslated = apply( + TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = firstChunkId, + index = 0, + text = "A better rendering.", + createdAt = 1_700_000_070L + ) + ) + val other = apply( + TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = secondChunkId, + index = 1, + text = "Wayenezinye eziningi.", + createdAt = 1_700_000_065L + ) + ) + recordEverythingApplied() + + assertEquals( + setOf(retranslated.id, other.id), + archivedTranslations().map { it.id }.toSet() + ) + } } diff --git a/docs/member-archive.md b/docs/member-archive.md index 5eb50732..14498cf7 100644 --- a/docs/member-archive.md +++ b/docs/member-archive.md @@ -373,10 +373,27 @@ end: a version sits between its artifact and the chapters hanging off it, and a translated chunk hangs off both a source chunk and a translation chapter, so it really is last. -**A retranslated passage archives once.** The arm that applies a translation -chunk drops the one it supersedes -- newest by the timestamp the group signed at, -id breaking a tie -- so a sender holds a group's current answer to each passage -rather than its drafts, and that is what travels. +**A retranslated passage archives once, and that stopped being free.** The arm +that applies a translation chunk drops the row it supersedes -- newest by the +timestamp the group signed at, id breaking a tie -- so while the archive was +rebuilt from rows, a sender simply had nothing but the group's current answer to +each passage and that is what travelled. + +Reading from `GroupSignedEvent` changed it. The record keeps every event the +group ever signed, deliberately: a signature is the group's statement and +discarding one is not that table's business. So a passage translated three times +leaves one row and three events, and an unfiltered read would put every draft a +group ever signed into every archive it ever sends, for as long as the room +exists. + +`ArchiveManager.currentTranslationsOnly` is what holds the original property up. +It restates the applying arm's rule rather than approximating it -- newest by +signed timestamp, id breaking a tie -- because an archive that shipped one +translation as current while the recipient settled on another would have both +validly signed and nothing downstream to notice the disagreement. Dropping the +drafts is safe precisely *because* the recipient applies the same rule: it is not +what keeps them correct, only what stops them being sent work they would discard +on arrival. ### Ordering