diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt index c6a96f5d..0e6bf12a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt @@ -19,6 +19,9 @@ 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.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -167,8 +170,8 @@ class ChronicleEvent( * ### What is missing from it, and why * * **Only kinds the group actually signs can be here**, because a chronicle - * that cannot be verified is one that has to be believed. Six of the - * thirteen nip30303 kinds reach a signing session; the rest travel as + * that cannot be verified is one that has to be believed. Eleven of the + * sixteen 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. * @@ -179,6 +182,8 @@ class ChronicleEvent( * - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has * no arm that writes a row for any of them, so chronicling them would * cost bytes and restore nothing. + * - `SubmissionEvent` (30312), which is the envelope rather than the + * work: a chronicle carries payloads. * * Two kinds were on that list and are not any more, because the app * stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be @@ -188,6 +193,10 @@ class ChronicleEvent( * and is now proposed to the group like everything else. Both are * therefore checkable, and a chronicle that left them out would hand a new * member the whole structure and none of the prose. + * + * The three project kinds (30330-30332) were signed from the day they + * existed, so they have no such story: they are here because a group's + * work includes what the work is for. */ private val APPLY_ORDER: List = listOf( DialectEvent.KIND, @@ -198,6 +207,20 @@ class ChronicleEvent( TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + // Projects are last, and that is the whole of what this list says + // about them. A membership is a foreign key into both a project and + // a version, so it has to apply after everything it can name -- and + // appending gets that right by construction, since everything a + // project groups is already above it. + // + // Chroniclable where the contributor lists are not, for the opposite + // of their reason: these have an arm that writes a row, and they are + // signed rather than submitted, so a receiver can check them. A late + // member handed the whole library and no account of what any of it + // is for has been handed the smaller half. + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ) /** Every kind a chronicle may carry. */ diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index ee16573b..0bb7fa12 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -19,6 +19,9 @@ 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.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent @@ -190,9 +193,56 @@ class ChronicleEventTest { TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") } - assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size) + assertEquals(11, ChronicleEvent.CHRONICLABLE_KINDS.size) + } + + @Test + fun `a membership applies after the project and the version it names`() { + // The only thing APPLY_ORDER has to get right about projects. A + // membership is a foreign key into both, so a page that applied one + // first would have the insert refused and the membership lost -- on the + // one device that most needs it, since a chronicle is what a member who + // joined late is given instead of having been there. + val project = ChronicleEvent.applyRank(ProjectEvent.KIND)!! + val source = ChronicleEvent.applyRank(ProjectArtifactVersionEvent.KIND)!! + val translation = ChronicleEvent.applyRank(ProjectTranslationArtifactVersionEvent.KIND)!! + + assertTrue(source > project, "a source membership must apply after its project") + assertTrue(translation > project, "a translation membership must apply after its project") + + assertTrue( + source > ChronicleEvent.applyRank(ArtifactVersionEvent.KIND)!!, + "a source membership must apply after the version it names" + ) + assertTrue( + translation > ChronicleEvent.applyRank(TranslationArtifactVersionEvent.KIND)!!, + "a translation membership must apply after the translation it names" + ) + } + + @Test + fun `a page of a whole project sorts into an order that will apply`() { + val shuffled = listOf( + payload(ProjectArtifactVersionEvent.KIND), + payload(ProjectEvent.KIND), + payload(ArtifactVersionEvent.KIND), + payload(ArtifactEvent.KIND), + ) + + assertEquals( + listOf( + ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ), + ChronicleEvent.inApplyOrder(shuffled).map { it.kind } + ) } @Test diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt index 7d53214a..e626d223 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt @@ -34,6 +34,9 @@ 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.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -408,15 +411,45 @@ class ChronicleRoundTripTest { } } + /** + * The kinds that reach a chronicle only off the record, never by rebuild. + * + * Everything else here has two sources, because a room whose work predates + * `GroupSignedEvent` at schema v13 has rows and no events, and the rebuild is + * what makes such a room whole. Projects arrive at v21, so no project row + * anywhere is in that position and a `toProjectEvent` would be new code for + * a mechanism with a removal checklist already written against it -- see + * `MantraProject` and docs/member-chronicle.md. + * + * The cost is real and is asserted rather than assumed: + * `ChronicleAssemblyJvmTest.a project is only ever chronicled off the record` + * is what happens when the record is missing. + */ + private val notRebuildable = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + @Test fun `every chroniclable kind has a round-trip test above`() { // The list and this file have to move together. A kind added to the // 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. + // + // Unless it is a kind with no rebuild at all, which is a decision rather + // than an omission -- see [notRebuildable]. Those are excluded here and + // pinned in `ChronicleAssemblyJvmTest` instead, so adding one is still a + // thing somebody has to write down. assertEquals( 8, - ChronicleEvent.CHRONICLABLE_KINDS.size, + (ChronicleEvent.CHRONICLABLE_KINDS - notRebuildable).size, "a chroniclable kind was added or removed; add or remove its round-trip case" ) + + assertTrue( + notRebuildable.all { it in ChronicleEvent.CHRONICLABLE_KINDS }, + "a kind cannot be exempted from the rebuild unless it is chroniclable at all" + ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index 6500d8c9..cd0eb5de 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -27,6 +27,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent @@ -44,6 +45,9 @@ 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.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -307,6 +311,43 @@ class ChronicleApplyJvmTest { ) ) ) + + // What the work is for. Unlike everything above it, a project has to be + // *recorded* as well as applied to reach a chronicle: there is no + // `toProjectEvent`, so the deprecated rebuild cannot produce one, and + // the record is its only source. That is deliberate -- the rebuild + // serves rooms older than schema v13 and projects arrive at v21 -- and + // it is why `record` appears here and nowhere else in this seed. + val project = signed( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = oldMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ).also { apply(sender, it) } + + val sourceMembership = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(version.id)).single() + ).also { apply(sender, it) } + + val translationMembership = signed( + ProjectTranslationArtifactVersionEvent + .membershipsOf(project, listOf(translationVersion.id)) + .single() + ).also { apply(sender, it) } + + sender.groupSignedEventDao().recordAll( + listOf(project, sourceMembership, translationMembership).map { + GroupSignedEvent.fromEvent( + event = it, + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(), + frostSigningSessionId = "session-1", + ) + } + ) } /** diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt index fe713e8d..a1c35dd1 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt @@ -37,6 +37,9 @@ 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.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -297,8 +300,40 @@ class ChronicleAssemblyJvmTest { createdAt = 1_700_000_060L ) ) + + // What the work is for. Seeded here rather than in the tests that are + // about projects, because `a chronicle holds every kind the group + // signed` compares what this room produces against CHRONICLABLE_KINDS + // itself -- so a kind added to that set and not to this seed fails three + // tests at once, which is the point of writing it that way. + // + // Proposed the way the form will propose it: the project leading, its + // memberships taking the id and the timestamp it comes back with. + seededProject = apply( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = newMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ) + + apply( + ProjectArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(version.id)) + .single() + ) + apply( + ProjectTranslationArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(translationVersion.id)) + .single() + ) } + /** The project [seedSignedWork] signed, for the tests that are about it. */ + private var seededProject: Event? = null + private suspend fun chronicledPayloads(): List = ChronicleManager.assemble(db, chatRoomId, newMember) .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } @@ -321,6 +356,12 @@ class ChronicleAssemblyJvmTest { @Test fun `a chronicle holds every kind the group signed, and each of them once`() = runBlocking { seedSignedWork() + // On file, which is what a room signing work today looks like. Without + // this the chronicle is assembled entirely by the deprecated rebuild, + // which reaches eight of the eleven kinds -- it has no `toProjectEvent` + // and deliberately never will. That path is still covered, by + // `work held both ways travels exactly once` and the two below it. + recordEverythingApplied() val byKind = chronicledPayloads().groupBy { it.kind } @@ -332,10 +373,33 @@ class ChronicleAssemblyJvmTest { assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size) assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size) assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size) + assertEquals(1, byKind[ProjectEvent.KIND]?.size) + assertEquals(1, byKind[ProjectArtifactVersionEvent.KIND]?.size) + assertEquals(1, byKind[ProjectTranslationArtifactVersionEvent.KIND]?.size) assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys) } + @Test + fun `the rebuild alone reaches everything but the projects`() = runBlocking { + // The same room with nothing on file, which is the deprecated path in + // isolation. Pinned rather than left implicit: if somebody adds a + // `toProjectEvent` this fails, and the comment on `MantraProject` says + // why they should not. + seedSignedWork() + + val kinds = chronicledPayloads().map { it.kind }.toSet() + + assertEquals( + ChronicleEvent.CHRONICLABLE_KINDS - setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + kinds + ) + } + @Test fun `a chronicle is in dependency order across its whole length`() = runBlocking { seedSignedWork() @@ -451,7 +515,24 @@ class ChronicleAssemblyJvmTest { // Half the batch on file, the other half only as rows -- which is what // a room that was upgraded partway through its life looks like. - record(*applied.take(applied.size / 2).toTypedArray()) + // + // The projects are on file whichever half they fall in, because the + // rebuild cannot reach them: there is no `toProjectEvent`, deliberately. + // That is not a gap in this scenario, it is the scenario being + // impossible for them -- the rebuild serves rooms whose work predates + // `GroupSignedEvent` at v13, and projects arrive at v21. See + // `a project is only ever chronicled off the record` for the edge that + // does follow from it. + val projectKinds = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + val rebuildable = applied.filterNot { it.kind in projectKinds } + record( + *(rebuildable.take(rebuildable.size / 2) + + applied.filter { it.kind in projectKinds }).toTypedArray() + ) val payloads = chronicledPayloads() @@ -749,4 +830,98 @@ class ChronicleAssemblyJvmTest { chronicledTranslations().map { it.id }.toSet() ) } + + // ---- Projects --------------------------------------------------------- + + @Test + fun `a room's projects travel in its chronicle`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + val project = assertNotNull(seededProject) + + val payloads = chronicledPayloads() + + assertTrue( + payloads.any { it.kind == ProjectEvent.KIND && it.id == project.id }, + "a late member should be told what the room's work is for" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectArtifactVersionEvent.KIND }, + "the project's source version should travel with it" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectTranslationArtifactVersionEvent.KIND }, + "the project's translation should travel with it" + ) + } + + @Test + fun `a chronicled project arrives in an order that applies`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + + val kinds = chronicledPayloads().map { it.kind } + + // The assertion that matters, and the reason projects went on the end of + // APPLY_ORDER rather than anywhere tidier: both memberships are foreign + // keys into the project and into a version, so either arriving first + // would be refused -- silently, on the one device that has nothing else + // to fall back on. + val project = kinds.indexOf(ProjectEvent.KIND) + assertTrue(project >= 0, "the project is in the chronicle") + + assertTrue( + kinds.indexOf(ProjectArtifactVersionEvent.KIND) > project && + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND) > project, + "memberships must come after their project" + ) + assertTrue( + kinds.indexOf(ArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectArtifactVersionEvent.KIND), + "a membership must come after the version it names" + ) + assertTrue( + kinds.indexOf(TranslationArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND), + "a membership must come after the translation it names" + ) + } + + /** + * The one thing "no `toProjectEvent`" costs, stated rather than discovered. + * + * Every other kind in a chronicle has two sources: the event as the group + * signed it, and a rebuild from the row when the signed event predates the + * table that keeps them. Projects have only the first, because that rebuild + * exists for rooms older than schema v13 and projects arrive at v21 -- there + * is no project row anywhere whose event was never recorded. + * + * Except by accident: `recordSignedEvents` catches its own failure and lets + * the session complete, correctly, since the signature is valid whether or + * not this device could write it down. A project applied through that gap is + * a row this device can show and cannot chronicle, and it will not come + * back -- which is worth knowing is possible rather than worth preventing, + * since the alternative is failing a ceremony that succeeded. + */ + @Test + fun `a project is only ever chronicled off the record`() = runBlocking { + seedSignedWork() + + // Everything on file except the project, which is the shape that gap + // leaves behind. + record(*applied.filterNot { it.kind == ProjectEvent.KIND }.toTypedArray()) + + val project = assertNotNull(seededProject) + + assertNotNull( + db.mantraProjectDao().getProjectById(project.id), + "the row is here either way" + ) + assertTrue( + chronicledPayloads().none { it.id == project.id }, + "and without its signed event there is nothing to chronicle" + ) + } }