diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index f9d011f1..2d2dbeda 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -68,12 +68,16 @@ data class MantraArtifactVersion( id = id, pubKey = publicKey, createdAt = createdAt.epochSeconds, + // Tag order matches ArtifactVersionEvent.initialVersionOf, which puts + // the alt first because `build` does, or the event id does not + // round-trip. It was the other way round here for as long as nothing + // called this -- see ArchiveRoundTripTest, which is what calls it now. tags = TagArrayBuilder() .addUnique( - ArtifactIdTag.assemble(artifactId) + AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION) ) .addUnique( - AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION) + ArtifactIdTag.assemble(artifactId) ) .build(), content = versionLabel, 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 c5a3e2d8..5aca0549 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt @@ -577,10 +577,12 @@ object ArchiveManager { .getArtifactVersionsByArtifactId(artifact.id) // The version an artifact starts life with carries the label the - // artifact was signed with, and `initialVersionOf` gives it the - // artifact's own timestamp. A row with none of those is an artifact - // this device cannot rebuild, which is a gap in the archive rather - // than a reason to abandon it. + // artifact was signed with, and `ArtifactVersionEvent.initialVersionOf` + // gives it the artifact's own timestamp. The label is still not on the + // artifact row -- `fromArtifactEvent` drops it -- so this is where it + // comes back from. An artifact with no such version is one this device + // cannot rebuild, which is a gap in the archive rather than a reason to + // abandon it. val versionLabel = versions .firstOrNull { it.createdAt == artifact.createdAt } ?.versionLabel @@ -592,6 +594,8 @@ object ArchiveManager { } versions.forEach { version -> + add(version.toArtifactVersionEvent()) + database.mantraChapterDao() .getChaptersByArtifactVersionId(version.id) .forEach { chapter -> @@ -609,7 +613,20 @@ object ArchiveManager { database.mantraTranslationChapterDao() .getTranslationChaptersByTranslationArtifactVersionId(translationVersion.id) - .forEach { add(it.toTranslationChapterEvent()) } + .forEach { translationChapter -> + add(translationChapter.toTranslationChapterEvent()) + + // Only the translation that survived supersession + // is here to be found: retranslating a passage + // changes the text and so the event id, and the + // arm that applies one drops what it replaces. So + // an archive carries a group's current answer to + // each passage rather than its drafts, which is + // the same thing every other member holds. + database.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapter.id) + .forEach { add(it.toTranslationChunkEvent()) } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt index f15b68a8..c3ae8347 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt @@ -15,11 +15,13 @@ import press.mantra.compose.nostr.archive.tags.ArchiveIdTag import press.mantra.compose.nostr.archive.tags.ArchivePageTag import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag 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 /** * 30327 @@ -170,35 +172,32 @@ class ArchiveEvent( * rumors -- empty signature, member author, vouched for by the MLS frame * they arrived in and by nothing that survives leaving it. * - * Left out for that reason, and this is a real limit rather than an - * oversight: + * Left out for that reason: * - * - `ArtifactVersionEvent` (30301). Never signed. The version an - * artifact starts life with is *derived* from the signed artifact by - * `MantraArtifactVersion.initialVersionOf`, which the artifact's own - * arm in `applyInnerEvent` runs -- so archiving the artifact brings its - * version along and the chapters hanging off it keep their foreign - * key. Later versions go through `MantraDao.addArtifactVersion` as - * rumors, and no screen calls it today. - * - `TranslationChunkEvent` (30309) -- **the translated text itself**. - * `MantraDao.saveTranslation` submits it as a member's rumor, because - * a translation is one member's work rather than a group decision. - * So an archive restores everything a translation hangs on and not the - * translation. See docs/member-archive.md for what closing that would - * take; it is not a line in this list. * - `TranslationEvent` (30311). Nothing builds one; the inbound arm * exists and no producer does. * - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has * no arm that writes a row for any of them, so archiving them would * cost bytes and restore nothing. + * + * Two kinds were on that list and are not any more, because the app + * stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be + * derived from the signed artifact on arrival, and is now the second item + * of the batch that carries it. `TranslationChunkEvent` (30309) -- the + * translated text itself -- used to be submitted as its author's rumor, + * and is now proposed to the group like everything else. Both are + * therefore checkable, and an archive that left them out would hand a new + * member the whole structure and none of the prose. */ private val APPLY_ORDER: List = listOf( DialectEvent.KIND, ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, ChapterEvent.KIND, ChunkEvent.KIND, TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, + TranslationChunkEvent.KIND, ) /** Every kind an archive may carry. */ diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt index fe011582..75345782 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt @@ -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 } ) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt index 78dd62f9..d6f274b3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt @@ -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" ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt index e2d7f5e7..2f3fc722 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt @@ -40,11 +40,13 @@ import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent 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 /** * Two devices, two databases: one that did the work and one that arrived after @@ -229,9 +231,10 @@ class ArchiveApplyJvmTest { ) ).also { apply(sender, it) } - val version = assertNotNull( - sender.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(artifact.id).firstOrNull() - ) + // The second item of the artifact's own signing batch, not a row derived + // on arrival -- which is what makes it archivable at all. + val version = signed(ArtifactVersionEvent.initialVersionOf(artifact).single()) + .also { apply(sender, it) } val chapter = signed( ChapterEvent.build( @@ -245,19 +248,16 @@ class ArchiveApplyJvmTest { ) ).also { apply(sender, it) } - apply( - sender, - signed( - ChunkEvent.build( - chapterId = chapter.id, - text = "He was a man of parts.", - index = 0, - wordCount = 6, - characterCount = 22, - createdAt = 1_700_000_030L - ) + val chunk = signed( + ChunkEvent.build( + chapterId = chapter.id, + text = "He was a man of parts.", + index = 0, + wordCount = 6, + characterCount = 22, + createdAt = 1_700_000_030L ) - ) + ).also { apply(sender, it) } apply( sender, signed( @@ -283,14 +283,27 @@ class ArchiveApplyJvmTest { ) ).also { apply(sender, it) } + val translationChapter = signed( + TranslationChapterEvent.build( + translationArtifactVersionId = translationVersion.id, + chapterId = chapter.id, + index = 0, + createdAt = 1_700_000_050L + ) + ).also { apply(sender, it) } + + // The translated text. Group-signed now, so it can travel and be checked + // -- until it was, an archive handed a new member the whole structure and + // none of the prose. apply( sender, signed( - TranslationChapterEvent.build( - translationArtifactVersionId = translationVersion.id, - chapterId = chapter.id, + TranslationChunkEvent.build( + translationChapterId = translationChapter.id, + chunkId = chunk.id, index = 0, - createdAt = 1_700_000_050L + text = "Wayeyindoda enezingxenye.", + createdAt = 1_700_000_060L ) ) ) @@ -356,6 +369,9 @@ class ArchiveApplyJvmTest { db.mantraTranslationChapterDao() .getTranslationChaptersByTranslationArtifactVersionId(it.id) } + val translationChunks = translationChapters.flatMap { + db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id) + } return mapOf( "dialects" to dialects.size, @@ -365,6 +381,7 @@ class ArchiveApplyJvmTest { "chunks" to chunks.size, "translationVersions" to translationVersions.size, "translationChapters" to translationChapters.size, + "translationChunks" to translationChunks.size, ) } @@ -395,11 +412,13 @@ class ArchiveApplyJvmTest { 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) - } + val translationChapters = translationVersions.flatMap { + db.mantraTranslationChapterDao() + .getTranslationChaptersByTranslationArtifactVersionId(it.id) + } + translationChapters.forEach { add(Triple(it.id, it.publicKey, it.signature)) } + translationChapters + .flatMap { db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id) } .forEach { add(Triple(it.id, it.publicKey, it.signature)) } }.sortedBy { it.first } } @@ -413,6 +432,7 @@ class ArchiveApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, + "translationChunks" to 0, ), rowCounts(receiver), "the new member starts with nothing, which is the whole problem" @@ -429,16 +449,11 @@ class ArchiveApplyJvmTest { 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) -> + // And every one of them is the room's own work, with no exception left. + // The artifact version used to be one -- derived on arrival, so authored + // by the group and carrying no signature to show for it -- and is signed + // in the artifact's own batch now, which is what let it into the archive. + fingerprints.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") } @@ -625,6 +640,7 @@ class ArchiveApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, + "translationChunks" to 0, ), rowCounts(receiver), "a bystander already holds the work; re-applying would only rewrite its provenance" 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 404f0849..6997d868 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt @@ -31,11 +31,13 @@ import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.archive.ArchiveEvent 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 /** * Assembling a room's archive out of the rows a device actually holds. @@ -195,12 +197,10 @@ class ArchiveAssemblyJvmTest { ) ) - // Derived from the artifact rather than signed, which is exactly why it - // is not archived and why its label has to be read back off this row. - val version = assertNotNull( - db.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(artifact.id).firstOrNull(), - "applying the artifact should have derived its first version" - ) + // The second item of the artifact's own batch, built the way the batch + // builds it. It used to be derived on arrival, which is why the archive + // once could not carry it: a derived row has no signature to check. + val version = apply(ArtifactVersionEvent.initialVersionOf(artifact).single()) val chapter = apply( ChapterEvent.build( @@ -214,7 +214,7 @@ class ArchiveAssemblyJvmTest { ) ) - apply( + val chunk = apply( ChunkEvent.build( chapterId = chapter.id, text = "He was a man of parts.", @@ -246,7 +246,7 @@ class ArchiveAssemblyJvmTest { ) ) - apply( + val translationChapter = apply( TranslationChapterEvent.build( translationArtifactVersionId = translationVersion.id, chapterId = chapter.id, @@ -254,6 +254,18 @@ class ArchiveAssemblyJvmTest { createdAt = 1_700_000_050L ) ) + + // The translated text, which the group signs now rather than each member + // saving their own. + apply( + TranslationChunkEvent.build( + translationChapterId = translationChapter.id, + chunkId = chunk.id, + index = 0, + text = "Wayeyindoda enezingxenye.", + createdAt = 1_700_000_060L + ) + ) } private suspend fun archivedPayloads(): List = @@ -283,10 +295,12 @@ class ArchiveAssemblyJvmTest { assertEquals(1, byKind[DialectEvent.KIND]?.size) assertEquals(1, byKind[ArtifactEvent.KIND]?.size) + assertEquals(1, byKind[ArtifactVersionEvent.KIND]?.size) assertEquals(1, byKind[ChapterEvent.KIND]?.size) assertEquals(2, byKind[ChunkEvent.KIND]?.size) assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size) assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size) + assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size) assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, byKind.keys) } diff --git a/docs/member-archive.md b/docs/member-archive.md index e4b6c3e9..49f62361 100644 --- a/docs/member-archive.md +++ b/docs/member-archive.md @@ -17,7 +17,7 @@ times worth reading: | what the plan said | what it turned out to be | |---|---| -| nine archivable kinds | six. Only six ever reach a signing session, and one of the three that do not is the translated text -- see [Phase 3](#what-is-actually-archivable-which-is-less-than-it-looks) | +| nine archivable kinds | six at first, eight now. The three that were unsigned were fixed in the app rather than worked around here -- see [Phase 3](#what-is-actually-archivable) | | `MAX_PAGE_EVENTS = 256` | 128. At 256 the byte cap always binds first and the count cap can never fire | | "assemble, order, pack and queue" | assemble only; queueing moved to Phase 5, next to the thing that decides when | | "re-read the room between the invite and the assembly" | unnecessary; that rule is about the MLS snapshot a commit is built on | @@ -321,27 +321,41 @@ Neither fault would have shown up as an error. Both produce a well-formed artifact whose id no longer matches its fields, which every receiver drops as a forgery, silently, one kind at a time. -### What is actually archivable, which is less than it looks +### What is actually archivable -Only six of the thirteen nip30303 kinds ever 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 -- so they cannot be put -in front of somebody who has no way to check them. +An archive can only carry what its receiver can check, so the list is exactly the +kinds a signing session produces. Eight of the thirteen nip30303 kinds do. | kind | | why | |---|---|---| -| 30304 Dialect, 30300 Artifact, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter | archivable | proposed through `proposeSigning`/`proposeSigningBatch` | -| 30301 ArtifactVersion | no | never signed; derived from the signed artifact on arrival, which is what keeps a chapter's foreign key satisfied without archiving it | -| 30309 TranslationChunk | no | **the translated text itself.** `MantraDao.saveTranslation` submits it as the member's own rumor | +| 30304 Dialect, 30300 Artifact, 30301 ArtifactVersion, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter, 30309 TranslationChunk | archivable | proposed through `proposeSigning` / `proposeSigningBatch` | | 30311 Translation | no | nothing builds one; the inbound arm exists and no producer does | | 30305, 30307, 30310 contributor lists | no | `applyInnerEvent` has no arm that writes a row for any of them | -The second row of "no" is the one that matters and it is not a detail: **an -archive restores everything a translation hangs on and not the translation.** A -new member gets the dialects, the artifacts, the chapters, the source chunks, -which translations exist and their chapter scaffolding -- the whole structure, -enough to start translating -- and none of the translated text. See -[What this does not do](#what-this-does-not-do). +**It was six when this was written, and the two that were missing were the two +that mattered.** An artifact version was derived from the signed artifact on +arrival -- a row naming the group as its author with no signature to show for it +-- and a translated chunk was submitted as its author's rumor by +`MantraDao.saveTranslation`. Neither could be put in front of somebody with no +way to check it, so an archive restored everything a translation hangs on and not +the translation. + +Both were fixed in the app rather than worked around here, in parallel with this +work and for their own reasons: `feat: sign an artifact's first version with it, +not derive it after` makes the version the second item of the artifact's batch, +and `feat: ask the group to sign a chunk's translation, not just save it` puts a +quorum behind the prose. Once each of them carried a signature there was nothing +left to argue about -- the allowlist grew by two and the caveat went away. + +The order is forced by the foreign keys, and 30301 and 30309 do not go on the +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. ### Ordering @@ -579,10 +593,11 @@ left out rather than written blind: both real paths are automatic, so this is for the case the automation misses, and it wants a screen to live on. -**Say what the new member cannot do.** Still unwritten, and still the part of -this most likely to be reported as a bug: an archive does not make its recipient -able to sign, and it does not carry the translated text. Both belong in front of -a member the first time they open a room they were added to late. +**Say what the new member cannot do.** Still unwritten, and now down to one +thing rather than two: an archive hands its recipient the group's whole signed +record, prose included, and does not make them able to *sign* anything. That is +the sentence a member wants the first time they open a room they were added to +late, and the first thing this will be reported as a bug for. --- @@ -715,28 +730,15 @@ general answer for the reason [frost-batch-signing.md](./frost-batch-signing.md#appendix--what-was-considered-and-rejected) gives for manifests. Worth revisiting once anything depends on completeness. -**The translated text is not archived.** The largest gap, and it follows from the -same rule everything else here follows from. A translation chunk is submitted by -`MantraDao.saveTranslation` as the member's own rumor, because a translation is -one member's work rather than a group decision -- so it carries no signature, and -an archive carrying it would be asking its recipient to believe whoever sent it. -A new member therefore receives the entire structure and none of the prose. - -Three ways out, in increasing order of how much they cost: - - - **Send them anyway, marked unverified**, and let the reader see which rows - came with a group signature and which came on one member's word. Cheap, and it - gives up the property that makes the rest of this safe, so it needs its own - screen language rather than a quiet inclusion. - - **Corroborate.** Every member's archive is an independent copy, so a - translation two members' archives agree on is a claim two devices make. That - is a real strengthening and it needs a second archive to compare against, - which the request path already makes ordinary. - - **Sign them.** The group already puts a quorum behind a chapter and its - chunks; putting one behind a translation would make it archivable like - everything else. It is also a product decision about whether translating is an - act of the group or of a member, which is not a decision this document gets to - make. +**Nothing unsigned is archived, and that is the whole list.** For a while it read +larger: the translated text was its author's rumor and an artifact's first +version was derived rather than signed, so neither could travel and a new member +got the structure and none of the prose. Both are signed now. What is left out is +`TranslationEvent`, which nothing builds, and the contributor lists, which +nothing applies -- so the rule and the list have stopped diverging, and the thing +to watch is that they do not drift apart again. The guard is +`ArchiveRoundTripTest`, which fails when a kind is added to the allowlist without +a case proving it can be rebuilt. **The chat is gone and stays gone.** By design, restated here because it is the first thing a new member will notice and the archive is what makes them expect