From f47db494541839868022324d0a8beeff07f4d933 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:39:38 +0200 Subject: [PATCH] feat(dialects): read the dialects an open session is already signing DialectScaffold.pendingIn, the half of the identity rule that rows cannot answer. Without it the feature has a race it would hit on first use: two admins open the picker, both see the same forty missing, both propose, and the room gets eighty dialects -- forty of them duplicates nothing will ever merge. Readable because FrostSigningRepository.unsignedEvents gives a batch's events at proposal time, before any signature, which is exactly when this has to be known. The lambda is passed in rather than imported, as ProposedEvent.summarize takes a resolver: it keeps the function pure, testable without a database and callable from a composable. COMPLETE and FAILED sessions are both excluded, for opposite reasons. A complete session's dialects are rows by now, so counting them would hide them behind "waiting for the group" rather than "already yours". A failed one left nothing behind and has to be proposable again -- the signing screen says as much, and a retried batch must be a fresh one anyway. A session whose items will not parse contributes nothing rather than throwing, and the trade is named where it is made: its dialects look selectable and may be proposed twice, which is visible and recoverable, against a picker that will not open at all, which leaves an admin no way to act. unsignedEventJson is written by this device from a template it built, so that branch means a damaged database rather than an odd proposal. Co-Authored-By: Claude Opus 5 --- .../compose/managers/DialectScaffold.kt | 71 +++++ .../compose/managers/DialectsInFlightTest.kt | 273 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt index 37efcea3..23d0cf7e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt @@ -1,7 +1,11 @@ package press.mantra.compose.managers +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.language.AfricanLanguage import press.mantra.compose.language.AfricanLanguages import press.mantra.compose.nostr.nip30303.DialectEvent @@ -73,6 +77,73 @@ object DialectScaffold { return catalogue.filterNot { keyOf(it) in held } } + /** + * The catalogue entries an open session is already signing. + * + * Without this the feature has a race it will hit on its first real use: two + * admins open the picker, both see the same forty missing, both propose, and + * the room gets eighty dialects -- forty of them duplicates that nothing will + * ever merge, because nothing makes a dialect unique. + * + * Readable because [unsignedEvents] gives a batch's events *at proposal + * time*, before any signature, which is exactly when this has to be known. + * It is passed in rather than imported for the reason + * `ProposedEvent.summarize` takes a resolver: it keeps this testable without + * a database and callable from a composable. + * + * [FrostSigningStage.COMPLETE] and [FrostSigningStage.FAILED] are both + * excluded, for opposite reasons. A complete session's dialects are rows by + * now, so counting them here would hide them behind the wrong explanation -- + * "waiting for the group" rather than "already yours". A failed session left + * nothing behind and must be proposable again; the signing screen tells a + * member as much, and a batch retried has to be a fresh one anyway. + * + * A session whose items cannot be read contributes nothing rather than + * throwing. That is a trade and worth naming: the cost is that its dialects + * look selectable and may be proposed twice, and the alternative is a picker + * that will not open at all. A duplicate dialect is visible and recoverable; + * a dead screen leaves an admin with no way to act. `unsignedEventJson` is + * written by this device from a template it built, so reaching this branch + * means the database is damaged rather than the proposal being odd. + */ + fun pendingIn( + sessions: List, + unsignedEvents: (List) -> List, + catalogue: List = AfricanLanguages.ALL, + ): List { + val inFlight = sessions + .filter { it.session.stage in OPEN_STAGES } + .flatMap { session -> + runCatching { unsignedEvents(session.items) }.getOrDefault(emptyList()) + } + .mapNotNullTo(mutableSetOf(), ::keyOfDialectEvent) + + return catalogue.filter { keyOf(it) in inFlight } + } + + /** The stages where a session is still going, and so is still a claim on a dialect. */ + private val OPEN_STAGES = setOf( + FrostSigningStage.COLLECTING_NONCES, + FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES, + ) + + /** + * [event] as a dialect's [Key], or null when it is not a dialect or is + * missing one of the two tags the identity is made of. + */ + private fun keyOfDialectEvent(event: Event): Key? { + if (event.kind != DialectEvent.KIND) return null + + val dialect = DialectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ) + + val code = dialect.language() ?: return null + val country = dialect.country() ?: return null + + return keyOf(code, country) + } + /** * One dialect event per entry, in the order given. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt new file mode 100644 index 00000000..858c796e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt @@ -0,0 +1,273 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * The dialects an open signing session is already signing. + * + * This is the half of the identity rule that rows cannot answer. Two admins open + * the picker, both see the same forty missing and both propose: without reading + * what is in flight the room gets eighty dialects, forty of them duplicates that + * nothing will ever merge. `FrostSigningRepository.unsignedEvents` makes a + * batch readable at proposal time, which is exactly when it has to be. + */ +class DialectsInFlightTest { + + private val groupKey = "a".repeat(64) + private val chatRoomId = "r".repeat(64) + + private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho") + private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya") + private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania") + + private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania) + + private fun unsigned(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** What the repository does, so the lambda under test is the real one. */ + private val unsignedEvents: (List) -> List = + { items -> items.map { Event.fromJson(it.unsignedEventJson) } } + + private fun session( + id: String, + stage: FrostSigningStage, + json: List, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "d".repeat(64), + threshold = 2, + participantCount = 3, + signerId = 1, + stage = stage, + ), + unorderedItems = json.mapIndexed { index, eventJson -> + FrostSigningItem( + sessionId = id, + itemIndex = index, + unsignedEventJson = eventJson, + eventId = "e$index".padEnd(64, 'f').take(64), + nonceRandom = "n$index".padEnd(64, 'f').take(64), + ) + }, + ) + + private fun dialectJson(language: AfricanLanguage) = unsigned( + DialectScaffold.dialectsOf(listOf(language), createdAt = 1_700_000_000L).single() + ).toJson() + + private fun waveFor( + languages: List, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + id: String = "s".repeat(64), + ) = session(id = id, stage = stage, json = languages.map(::dialectJson)) + + @Test + fun anOpenSessionsDialectsArePending() { + assertEquals( + listOf(sesotho, swahiliKenya), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho, swahiliKenya))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aSessionCollectingSignaturesIsStillOpen() { + assertEquals( + listOf(sesotho), + DialectScaffold.pendingIn( + sessions = listOf( + waveFor( + listOf(sesotho), + stage = FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES + ) + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** It left nothing behind, so it has to be proposable again. */ + @Test + fun aFailedSessionsDialectsAreNotPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.FAILED)), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** + * Its dialects are rows by now, so counting them here would hide them behind + * "waiting for the group" rather than "already yours". + */ + @Test + fun aCompleteSessionsDialectsAreNotPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.COMPLETE)), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun twoOpenWavesAreReadTogether() { + assertEquals( + listOf(sesotho, swahiliTanzania), + DialectScaffold.pendingIn( + sessions = listOf( + waveFor(listOf(sesotho), id = "1".repeat(64)), + waveFor(listOf(swahiliTanzania), id = "2".repeat(64)), + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aSessionSigningSomethingElseContributesNothing() { + val chapter = unsigned( + ChapterEvent.build( + artifactVersionId = "b".repeat(64), + name = "Chapter 1", + originalText = "Paragraph.", + index = 0, + wordCount = 1, + characterCount = 10, + ) + ).toJson() + + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf( + session( + id = "s".repeat(64), + stage = FrostSigningStage.COLLECTING_NONCES, + json = listOf(chapter), + ) + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aDialectTheCatalogueDoesNotListIsNotReported() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(AfricanLanguage("Nheengatu", "yrl", "Brazil")))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** + * A damaged session must not take the picker down with it, and the other + * session's dialects still have to be read. See the trade named on + * `pendingIn`: its own dialects look selectable again, which is recoverable, + * and a screen that will not open is not. + */ + @Test + fun anUnreadableSessionIsSkippedRatherThanThrown() { + assertEquals( + listOf(swahiliKenya), + DialectScaffold.pendingIn( + sessions = listOf( + session( + id = "1".repeat(64), + stage = FrostSigningStage.COLLECTING_NONCES, + json = listOf("not an event"), + ), + waveFor(listOf(swahiliKenya), id = "2".repeat(64)), + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun noSessionsMeansNothingPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = emptyList(), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** The two halves together, which is how the picker reads them. */ + @Test + fun missingExcludesBothWhatIsHeldAndWhatIsInFlight() { + val pending = DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(swahiliKenya))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + + assertEquals( + listOf(swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf( + MantraDialect( + id = "d".repeat(64), + publicKey = groupKey, + name = "Sesotho", + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = chatRoomId, + ) + ), + pending = pending, + catalogue = catalogue, + ) + ) + } +}