From 5fac2fc7b726d34cd6510d4ac906fa05c70bc0ff Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:27:02 +0200 Subject: [PATCH] feat(projects): a late member is told what the room's work is for Phase 5 of docs/project-feature.md. Three kinds appended to `ChronicleEvent.APPLY_ORDER`, which is all the mechanism needs: `CHRONICLABLE_KINDS`, `applyRank`, `inApplyOrder` and `isChroniclable` all derive from that one list, and `signedEventsOf` already filters on it. Appending is not arbitrary. A membership is a foreign key into both a project and a version, so it has to apply after everything it can name, and putting the three on the end gets that right by construction -- everything a project groups is already above it. A page in the wrong order is a refused insert on the one device with nothing to fall back on. Then four existing tests failed, and each was telling the truth. Three of them compare what a seeded room chronicles against CHRONICLABLE_KINDS itself, so a kind added to the set and not to the seed fails them. That is the point of writing them that way, so the project went into the canonical seed in both chronicle suites rather than into a helper beside them. The fourth is the one worth reading. `every chroniclable kind has a round-trip test above` asserts that every kind a chronicle carries can also be rebuilt from its row -- and projects deliberately cannot, because the rebuild exists for rooms older than schema v13 and projects arrive at v21. So the meta-test now excludes them explicitly, and the gap is pinned where it actually bites: `a project is only ever chronicled off the record` holds a project whose `GroupSignedEvent` is missing and shows the row is there and the chronicle is not. That is reachable -- `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed -- and it is worth knowing is possible rather than worth preventing. `the rebuild alone reaches everything but the projects` pins the other side, so anyone adding a `toProjectEvent` fails a test that tells them where the comment explaining why is. Also corrected a stale count in the allowlist's own comment: it said six of thirteen nip30303 kinds reach a signing session while the list below it had eight. Eleven of sixteen now, with `SubmissionEvent` named among the exclusions since it is the envelope rather than the work. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/nostr/chronicle/ChronicleEvent.kt | 27 ++- .../nostr/chronicle/ChronicleEventTest.kt | 52 ++++- .../nostr/chronicle/ChronicleRoundTripTest.kt | 35 +++- .../compose/managers/ChronicleApplyJvmTest.kt | 41 ++++ .../managers/ChronicleAssemblyJvmTest.kt | 177 +++++++++++++++++- 5 files changed, 327 insertions(+), 5 deletions(-) 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" + ) + } }