fix: archive the translation that stands, not every draft of it
Follow-up to reading the archive out of `GroupSignedEvent` rather than rebuilding it from rows. The two sources do not hold the same thing, and one place where they differ reaches the archive. `ChatMessage.applyInnerEvent` supersedes a translation chunk: retranslating a passage changes the text and so the event id, so the arm drops the row it replaces -- newest by the timestamp the group signed at, id breaking a tie. The record does not, and should not: 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. While the archive was rebuilt from rows that difference was invisible, because a sender simply had nothing but the group's current answer to each passage. Read from the record it is not: measured on a seeded room, one retranslation leaves one row and puts **two** payloads in the archive, and it compounds -- every draft a group ever signed would travel in every archive it ever sends, for as long as the room exists. **The rule is the applying arm's, restated rather than approximated.** An archive that shipped one translation as current while the recipient settled on another would have both validly signed and nothing downstream to notice they disagree, so `currentTranslationsOnly` groups by `(translationChapterId, chunkId)` and keeps the maximum by `(createdAt, id)` -- the same comparison, spelled the same way. Dropping the drafts is safe precisely *because* the recipient applies that rule too. This is not what keeps them correct; it is what stops them being sent work they would discard on arrival. Grouped per passage rather than per chapter, or retranslating one passage would take every other passage's translation with it. A translation naming no passage is left alone rather than lumped in with the rest: it is unappliable either way, and letting one stand in for a whole passage would let a malformed event suppress a good one. Three tests, and all three fail if the filter is removed: a retranslated passage leaves one row, two recorded events and one payload; three translations signed in the same second settle on the same id the row keeps, which is what pins the tiebreak to the applying arm's; and two passages each keep their own, which is what a group-by-chapter mistake would fail. Two documentation corrections alongside it. docs/member-archive.md said "a retranslated passage archives once" as a property of the rows, which stopped being true the moment the record became the source -- it now says what makes it true again. And `GroupSignedEvent.verifies()` said a false means the row's columns have drifted from the event they came from. That is the reading worth acting on and it is not the only one: a room never derived from its group's key signs as the bare threshold key rather than as its own id, so a perfectly good event there fails and cannot be made to pass, the key it would need being absent from the row and unreachable from one. 498 jvm tests and 297 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<Event>): List<Event> {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<Event> =
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user