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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 22:39:38 +02:00
parent 08dc412594
commit f47db49454
2 changed files with 344 additions and 0 deletions

View File

@@ -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<LocalFrostSigningSession>,
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<AfricanLanguage> {
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.
*

View File

@@ -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<FrostSigningItem>) -> List<Event> =
{ items -> items.map { Event.fromJson(it.unsignedEventJson) } }
private fun session(
id: String,
stage: FrostSigningStage,
json: List<String>,
) = 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<AfricanLanguage>,
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,
)
)
}
}