diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt index 528ab188..160d5960 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt @@ -24,6 +24,21 @@ interface MantraTranslationArtifactVersionDao { ) suspend fun getTranslationsByArtifactId(artifactId: String): List + /** + * Translations of one version, which is what a chapter of that version has + * to be scaffolded into. Narrower than [getTranslationsByArtifactId]: a + * chapter belongs to a version, and a translation of an older version is + * not a translation the chapter is in. + */ + @Query( + """ + SELECT * FROM MantraTranslationArtifactVersion + WHERE artifactVersionId = :artifactVersionId + ORDER BY createdAt DESC + """ + ) + suspend fun getTranslationsByArtifactVersionId(artifactVersionId: String): List + @Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id") suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion? } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index 58ccf9c1..8737dba9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -138,6 +138,9 @@ class DatabaseFrostSigningRepository( } } + override fun unsignedEvents(items: List): List = + items.map { Event.fromJson(it.unsignedEventJson) } + override fun signedEvents(items: List): List = FrostSigningManager.signedEvents(items) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index e66a8db5..39967f2e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -73,6 +73,9 @@ class DatabaseMantraRepository( override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = database.mantraTranslationArtifactVersionDao().getTranslationById(id) + override suspend fun getTranslationsForArtifactVersion(artifactVersionId: String): List = + database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactVersionId(artifactVersionId) + override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? = database.mantraTranslationChapterDao().getTranslationChapterById(id) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 036fc23f..5b9d5edd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -119,6 +119,17 @@ interface FrostSigningRepository { /** Refuses, and says so, since a t-of-n group can proceed without this member. */ suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) + /** + * The events a session was opened over, signature or no signature. + * + * What [signedEvents] gives back once the group has answered, and what it + * refuses to give back before then. A caller wanting an id -- to open a + * second session over events that name one of these -- needs it while the + * first session is still running, and the id is settled at proposal time: + * it is the hash the signers are putting their shares behind. + */ + fun unsignedEvents(items: List): List + /** The finished events, or empty while any of them is still running. */ fun signedEvents(items: List): List @@ -168,6 +179,8 @@ interface FrostSigningRepository { override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit + override fun unsignedEvents(items: List): List = emptyList() + override fun signedEvents(items: List): List = emptyList() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 8e841359..1bf79271 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -53,6 +53,13 @@ interface MantraRepository { suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? + /** + * The translations of one artifact version: what a chapter signed into that + * version has to be scaffolded into, and what a translation is caught up + * against. + */ + suspend fun getTranslationsForArtifactVersion(artifactVersionId: String): List + suspend fun getTranslationChapter(id: String): MantraTranslationChapter? suspend fun getTranslationChapters(translationArtifactVersionId: String): List @@ -105,6 +112,8 @@ interface MantraRepository { override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null + override suspend fun getTranslationsForArtifactVersion(artifactVersionId: String): List = emptyList() + override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? = null override suspend fun getTranslationChapters(translationArtifactVersionId: String): List = emptyList() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 304d16a8..1a09371b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -263,11 +263,12 @@ fun AddTranslationArtifactVersionScreen( // What the group is being asked to sign, counted before // the tap rather than described after it: the chapters // are the rest of the batch. The empty case is worth - // its own sentence, because a translation proposed now - // is not reached by chapters signed after it. + // its own sentence, so that a translation of an artifact + // nobody has written into yet does not read as broken. text = if (chapterCount == 0) { "The artifact has no chapters yet, so the group would sign the " + - "translation on its own. Chapters added afterwards do not join it." + "translation on its own. Chapters signed afterwards are put " + + "into it as they are added." } else { "The group signs the translation and " + "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index bee42019..bbe3e9d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.repository.ChatRepository @@ -114,15 +115,19 @@ class AddChapterViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { + // Where this chapter sits in the version, read now rather than + // derived on arrival: the index is signed into the event, and a + // device applying it cannot recount from a version other members + // may have added to in a different order. The scaffolding proposed + // below is placed at the same one, so a translation chapter agrees + // with the chapter it stands for. + val chapterIndex = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size + val chapterEventTemplate = ChapterEvent.build( artifactVersionId = artifactVersion.id, name = name, originalText = originalText, - // Where this chapter sits in the version, read now rather than - // derived on arrival: the index is signed into the event, and a - // device applying it cannot recount from a version other members - // may have added to in a different order. - index = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size, + index = chapterIndex, wordCount = Markdown.wordCount(originalText), characterCount = Markdown.characterCount(originalText), ) @@ -143,6 +148,17 @@ class AddChapterViewModel( }.getOrNull() if (session != null) { + // Before navigating, not after: the route this screen sits on + // is popped on success, which clears this view model and takes + // `viewModelScope` with it. Scaffolding launched afterwards + // would be cancelled somewhere in the middle. + scaffoldIntoTranslations( + localChatRoom = localChatRoom, + artifactVersion = artifactVersion, + chapterIndex = chapterIndex, + chapterSessionId = session.id, + ) + nameField.clearText() originalTextField.clearText() @@ -159,6 +175,81 @@ class AddChapterViewModel( } } + /** + * Asks the group to scaffold this chapter into every translation of the + * version, in sessions of their own. + * + * Its own session rather than more items on the chapter's, because the two + * would otherwise compete for one batch: a chapter is capped at + * [MAX_CHUNKS_PER_CHAPTER] paragraphs, and every dialect the group works in + * would take one of those places away. How long a chapter may be and how + * many languages it is read in have nothing to do with each other, and a + * shared cap would make the first move whenever somebody else did the + * second. + * + * Sessions run independently -- every message carries the session it + * belongs to, and each mints its own nonce material -- so a second one + * opened here costs the admins another approval and nothing else. + * + * **What a separate session gives up.** A batch is all-or-nothing; two + * sessions are not. If the chapter fails to reach a quorum while this one + * succeeds, these are valid signatures over rows naming a chapter nobody + * has: they fail a foreign key on the way in, are logged by + * `applySignedEvent`, and never become rows. Harmless, and not recoverable + * either -- a re-proposed chapter is a different id -- so the catch-up on + * the translation is what fills the gap afterwards. + * + * The chapter is named as the group will author it: [chapterSessionId]'s + * first item is the unsigned chapter, whose id is the hash its signers are + * putting their shares behind, and it is settled at proposal time rather + * than when the quorum arrives. + * + * Failure here is logged and swallowed. The chapter has already been + * proposed and is the thing that was asked for; losing it because its + * scaffolding could not be opened would be the wrong way round. + */ + private suspend fun scaffoldIntoTranslations( + localChatRoom: LocalChatRoom, + artifactVersion: MantraArtifactVersion, + chapterIndex: Int, + chapterSessionId: String, + ) { + runCatching { + val translations = mantraRepository + .getTranslationsForArtifactVersion(artifactVersion.id) + .ifEmpty { return@runCatching } + + val chapter = frostSigningRepository + .unsignedEvents(frostSigningRepository.getItems(chapterSessionId)) + .firstOrNull() ?: return@runCatching + + // One session per MAX_BATCH_SIZE translations. A group with more + // dialects than a batch holds is not a group this should refuse; + // it is one that answers twice. + translations.chunked(FrostSigningManager.MAX_BATCH_SIZE).forEach { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = batch.map { it.id }, + chapters = listOf( + TranslationScaffold.SourceChapter( + id = chapter.id, + index = chapterIndex, + ) + ), + createdAt = chapter.createdAt, + ) + ) + } + }.onFailure { error -> + logger.e( + "Proposed the chapter but could not ask for it in this version's translations", + error + ) + } + } + companion object { private const val TAG = "AddChapterViewModel" diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt index 6317d730..dee6f2cf 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt @@ -10,6 +10,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase @@ -24,8 +25,12 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.view.model.AddChapterViewModel import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel /** @@ -267,6 +272,113 @@ class TranslationBatchProposalJvmTest { assertEquals(FrostSigningManager.MAX_BATCH_SIZE, events.size) } + /** Exactly what `AddChapterViewModel.addChapter` proposes: the chapter, then its chunks. */ + private suspend fun proposeChapter(paragraphs: Int, index: Int = 0) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = (1..paragraphs).joinToString("\n\n") { "Paragraph $it." }.let { text -> + ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = "Chapter ${index + 1}", + originalText = text, + index = index, + wordCount = Markdown.wordCount(text), + characterCount = Markdown.characterCount(text), + ) + }, + dependents = ChunkEvent::splitOf + ) + + /** And what it proposes straight afterwards: that chapter, into every translation. */ + private suspend fun proposeScaffolding(chapter: Event, index: Int, translationIds: List) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + events = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = translationIds, + chapters = listOf(TranslationScaffold.SourceChapter(id = chapter.id, index = index)), + createdAt = chapter.createdAt, + ) + ) + + @Test + fun `a chapter's scaffolding runs beside it as a session of its own`() = runBlocking { + openDevice() + + val translationIds = listOf("1".repeat(64), "2".repeat(64), "3".repeat(64)) + + val chapterSession = proposeChapter(paragraphs = 2) + val chapter = itemEvents(chapterSession.id).first() + val scaffoldSession = proposeScaffolding(chapter, index = 0, translationIds = translationIds) + + // Two live sessions in one room, which is the whole point: every FROST + // message carries the session it belongs to, so neither is the room's + // "current" one and nothing had to be finished before the second + // opened. + assertNotEquals(chapterSession.id, scaffoldSession.id) + assertEquals(2, db.frostSigningSessionDao().getSessionsForChatRoom(roomId).size) + + // The chapter's batch is what it would have been with no translations + // at all: the chapter and a chunk per paragraph, and nothing else. + val chapterItems = itemEvents(chapterSession.id) + assertEquals(3, chapterItems.size) + assertEquals(ChapterEvent.KIND, chapterItems.first().kind) + assertTrue(chapterItems.drop(1).all { it.kind == ChunkEvent.KIND }) + + // And the scaffolding names that chapter, in every translation. + val scaffolded = itemEvents(scaffoldSession.id).map { + TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + assertEquals(translationIds, scaffolded.map { it.translationArtifactVersionId() }) + assertTrue(scaffolded.all { it.chapterId() == chapter.id }) + } + + @Test + fun `the two sessions never share nonce material`() = runBlocking { + openDevice() + + val chapterSession = proposeChapter(paragraphs = 2) + val chapter = itemEvents(chapterSession.id).first() + val scaffoldSession = proposeScaffolding( + chapter, + index = 0, + translationIds = listOf("1".repeat(64), "2".repeat(64)) + ) + + // The one thing concurrency could get wrong. Two partial signatures + // under a single secret nonce are two equations in one unknown, and the + // share falls out; seeds are minted per item per session precisely so + // that running two at once cannot arrange it. + val seeds = ( + db.frostSigningSessionDao().getItems(chapterSession.id) + + db.frostSigningSessionDao().getItems(scaffoldSession.id) + ).map { it.nonceRandom } + + assertEquals(5, seeds.size) + assertEquals(seeds.size, seeds.distinct().size) + } + + @Test + fun `the caps do not compete`() = runBlocking { + openDevice() + + // The reason the scaffolding is a second session rather than more items + // on the chapter's. A chapter of the longest allowed length still + // proposes with translations waiting for it; sharing one batch, every + // dialect the group worked in would have cost it a paragraph. + val translationIds = (1..8).map { it.toString().repeat(64).take(64) } + + val chapterSession = proposeChapter(paragraphs = AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER) + val chapter = itemEvents(chapterSession.id).first() + val scaffoldSession = proposeScaffolding(chapter, index = 0, translationIds = translationIds) + + assertEquals(FrostSigningManager.MAX_BATCH_SIZE, itemEvents(chapterSession.id).size) + assertEquals(translationIds.size, itemEvents(scaffoldSession.id).size) + } + @Test fun `an artifact of one chapter too many is refused`() = runBlocking { openDevice()