feat: archive the translated text too, now that the group signs it

The merge brought in two commits that close the gap this feature was written
around, so the allowlist grows from six kinds to eight.

`feat: sign an artifact's first version with it, not derive it after` makes the
version the second item of the artifact's own signing batch. `feat: ask the group
to sign a chunk's translation, not just save it` puts a quorum behind the prose.
Both were done for their own reasons and neither was about the archive, but they
are exactly what the archive was missing: an archive can only carry what its
recipient can check, so a derived version and a member-authored translation could
not travel. A new member got the whole structure and none of the words.

**30301 and 30309 do not go on the end of the list.** The order is the foreign
keys: 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 that
one really is last.

**`toArtifactVersionEvent` had the bug this predicted it would.** It emitted
[artifactId, alt] where `build` emits [alt, artifactId], so the id did not
round-trip -- the same fault fixed on `MantraArtifact.toArtifactEvent` in Phase 3,
in the second of the three unused rebuilds, and for the same reason: nothing had
ever called it, so the "tag order matches build" claim in its comment was never
checked. `toTranslationChunkEvent` was already correct. Both now have a
round-trip case, which is what makes the difference between a rebuild that is
right and one that has not been contradicted yet.

**`signedEventsOf` walks two steps further**, emitting each version and the
translation chunks under each translation chapter. 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 what a sender holds, and therefore what travels, is the group's current answer
to each passage rather than its drafts.

**The seeds had to change with it.** Both database tests derived the artifact's
first version by applying the artifact, which is exactly what stopped happening;
they now sign it through `ArtifactVersionEvent.initialVersionOf`, the way the
batch does. That also removes the one exception in the end-to-end assertion:
every archived row is now authored by the room and carries a signature, where the
artifact version used to have to be excused for having neither.

480 tests pass. The plan's Phase 3 table, its built-vs-plan table and its "what
this does not do" section are updated -- what an archive cannot do is down from
two things to one, and the remaining one is that it still cannot make its
recipient able to sign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 15:13:43 +02:00
parent 54091099a9
commit 47aa79ebc7
8 changed files with 253 additions and 130 deletions

View File

@@ -183,33 +183,37 @@ class ArchiveEventTest {
listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") }
assertEquals(6, ArchiveEvent.ARCHIVABLE_KINDS.size)
assertEquals(8, ArchiveEvent.ARCHIVABLE_KINDS.size)
}
@Test
fun `the kinds nobody signs are left out, translations included`() {
// The limit worth knowing about before reading anything else here. An
// archive can only carry what the receiver can check, and six of the
// thirteen nip30303 kinds reach a signing session. The rest travel as
// rumors -- empty signature, member author -- vouched for by the MLS
// frame they arrived in and by nothing that survives leaving it.
fun `the translated text is archivable, now that the group signs it`() {
// Worth its own case because it was the feature's headline limitation for
// as long as `saveTranslation` wrote the row and queued the member's own
// rumor. An archive can only carry what the receiver can check, so a
// translation nobody signed could not travel, and a new member got the
// whole structure and none of the prose.
assertTrue(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
// The translated text. MantraDao.saveTranslation submits it as the
// member's own rumor, so an archive restores everything a translation
// hangs on and not the translation.
assertFalse(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
// Same shape, same fix: the version an artifact starts life with used to
// be derived on arrival, so it was authored by the group with no
// signature to show for it. It is the second item of the artifact's batch
// now.
assertTrue(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
}
// Never signed either: derived from the signed artifact on arrival, which
// is what keeps a chapter's foreign key satisfied without archiving it.
assertFalse(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
// Nothing builds one of these at all.
@Test
fun `the kinds nobody signs are still left out`() {
// Nothing builds one of these at all; the inbound arm exists and no
// producer does.
assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND))
}
@@ -253,18 +257,18 @@ class ArchiveEventTest {
// Every one of these is a foreign key in Room, so an archive applied the
// other way round is a constraint violation rather than a wrong answer.
assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND))
assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND))
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(ChapterEvent.KIND))
assertTrue(rank(ChapterEvent.KIND) < rank(ChunkEvent.KIND))
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
assertTrue(rank(TranslationArtifactVersionEvent.KIND) < rank(TranslationChapterEvent.KIND))
assertTrue(rank(ChapterEvent.KIND) < rank(TranslationChapterEvent.KIND))
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
// A chapter and a translation artifact version both hang off an artifact
// *version*, which is not archived: it is derived from the signed
// artifact by the artifact's own arm in applyInnerEvent. So the artifact
// has to land before either of them, and the dependency runs through a
// kind that is not in this list at all.
assertTrue(rank(ArtifactEvent.KIND) < rank(ChapterEvent.KIND))
assertTrue(rank(ArtifactEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
// A translated chunk hangs off both sides at once -- the source chunk it
// translates and the translation chapter it sits in -- so it is last.
assertTrue(rank(ChunkEvent.KIND) < rank(TranslationChunkEvent.KIND))
assertTrue(rank(TranslationChapterEvent.KIND) < rank(TranslationChunkEvent.KIND))
// And the numbers really do disagree with the order, which is why this is
// a list rather than a sortedBy { kind }.
@@ -275,11 +279,13 @@ class ArchiveEventTest {
@Test
fun `sorting a page puts what is referenced before what refers to it`() {
val jumbled = listOf(
payload(TranslationChunkEvent.KIND),
payload(TranslationChapterEvent.KIND),
payload(ChunkEvent.KIND),
payload(DialectEvent.KIND),
payload(TranslationArtifactVersionEvent.KIND),
payload(ChapterEvent.KIND),
payload(ArtifactVersionEvent.KIND),
payload(ArtifactEvent.KIND),
)
@@ -287,10 +293,12 @@ class ArchiveEventTest {
listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
),
ArchiveEvent.inApplyOrder(jumbled).map { it.kind }
)

View File

@@ -19,20 +19,24 @@ import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
/**
* The assumption the whole archive rests on: a row can be turned back into the
@@ -79,6 +83,8 @@ class ArchiveRoundTripTest {
private val artifactVersionId = "c".repeat(64)
private val chapterId = "d".repeat(64)
private val translationArtifactVersionId = "e".repeat(64)
private val translationChapterId = "f".repeat(64)
private val chunkId = "a".repeat(64)
/** Exactly what `FrostSigningManager.unsignedEventOf` does. */
private fun unsignedEventOf(template: EventTemplate<*>) = Event(
@@ -258,6 +264,63 @@ class ArchiveRoundTripTest {
assertFalse(GroupKeyStateEvent.isSignedByRoom(rebuilt, chatRoomId))
}
@Test
fun `an artifact version survives the trip through a row`() {
// The second fault of the same shape, found the moment this kind became
// archivable: `toArtifactVersionEvent` emitted [artifactId, alt] where
// `build` emits [alt, artifactId]. It had been that way for as long as
// nothing called it, which is what makes an unused rebuild dangerous
// rather than merely dead -- the claim in its comment was never checked.
//
// Built through `initialVersionOf` rather than by hand, because that is
// what the signing batch actually proposes.
val artifact = signed(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = "1.0",
createdAt = 1_700_000_000L
)
)
assertRoundTrips(
ArtifactVersionEvent.initialVersionOf(artifact).single()
) { event ->
MantraArtifactVersion.fromArtifactVersionEvent(
ArtifactVersionEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
),
chatRoomId
)?.toArtifactVersionEvent()
}
}
@Test
fun `a translated chunk survives the trip through a row`() {
// The translated text. Archivable only because the app stopped saving it
// as its author's rumor and started asking the group to sign it -- until
// then a new member got the whole structure and none of the prose.
assertRoundTrips(
TranslationChunkEvent.build(
translationChapterId = translationChapterId,
chunkId = chunkId,
index = 0,
text = "Wayeyindoda enezingxenye.",
createdAt = 1_700_000_000L
)
) { event ->
MantraTranslationChunk.fromTranslationChunkEvent(
TranslationChunkEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
),
chatRoomId
)?.toTranslationChunkEvent()
}
}
@Test
fun `a chapter survives the trip through a row`() {
assertRoundTrips(
@@ -343,7 +406,7 @@ class ArchiveRoundTripTest {
// allowlist without a case here is one nobody has checked can be rebuilt,
// and the failure is silent: the receiver drops it as a forgery.
assertEquals(
6,
8,
ArchiveEvent.ARCHIVABLE_KINDS.size,
"an archivable kind was added or removed; add or remove its round-trip case"
)