feat: put a new chapter into the artifact's translations, in a session of its own

A translation covered the chapters that existed when it was proposed, and
nothing put a later one into it. The chapter was signed, every device applied
it, and in every translation of that artifact it simply was not there -- no
translation chapter to hang translated chunks off, so the text could not be
worked on at all. Invisible from the translation, which lists what it has.

Adding a chapter now proposes twice: the chapter and its chunks as before, then
a second session carrying one translation chapter per translation of that
version.

**Why a second session rather than more items on the first.** Because the two
would compete for one batch. A session signs at most MAX_BATCH_SIZE events, a
chapter is capped at MAX_CHUNKS_PER_CHAPTER paragraphs against it, and every
dialect the group works in would have taken 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 sharing the cap would have moved the first under whoever was
typing whenever somebody else did the second.

Nothing had to be built for a room to run two at once. Every FROST message
carries the session it belongs to and everything is looked up by that id, so
there is no "current" session on a room -- `liveSessionForChatRoom` is a
fallback for a screen opened without one, not state the protocol keeps. And
`itemsOver` mints an independent nonce seed per item per session, so two
sessions running together can no more share an `R` than two items of one batch
can. The cost is one more approval for the admins.

**Naming a chapter nobody has signed yet.** The scaffolding needs the chapter's
id, and the chapter is not signed for as long as a quorum takes. It does not
have to be: the id is settled when the session opens -- it is the hash every
signer puts their share behind -- so the second proposal reads the first
session's item 0. `FrostSigningRepository.unsignedEvents` is that read, the
counterpart of `signedEvents`, which by design gives back nothing until the
group has answered.

**What two sessions give up.** A batch is all-or-nothing; two batches are not.
If the chapter fails to reach a quorum while its scaffolding succeeds, those are
valid signatures over rows naming a chapter nobody has: they fail a foreign key
on the way in, `applySignedEvent` logs them, and they never become rows. Nor is
it recoverable -- a re-proposed chapter is a different id -- so those signatures
are simply spent. Harmless, and the reason the next commit adds a catch-up.

It also does not close the race. A chapter and a translation proposed at the
same moment see neither the other, because both read what exists when they are
proposed. No snapshot can fix that, which is again the catch-up's job.

**Failure is one-way.** Scaffolding runs before navigating, not after: the route
this screen sits on is popped on success, which clears the view model and takes
`viewModelScope` with it, so anything launched afterwards would be cancelled
somewhere in the middle. And a failure to open it is logged and swallowed -- the
chapter is what was asked for and has already been proposed, and losing it
because its scaffolding could not be opened would be the wrong way round.

**The chapter's index, once.** It was read inline into the event; it is now a
val, because the scaffolding has to place the translation chapter at the same
one the chapter is signed at.

`MantraTranslationArtifactVersionDao.getTranslationsByArtifactVersionId` is the
new read, narrower than the by-artifact one: a chapter belongs to a version, and
a translation of an older version is not one it is in.

**Tests.** Three in TranslationBatchProposalJvmTest, against a real database and
a real ceremony. Two sessions coexist in one room with the chapter's own batch
untouched -- the chapter and a chunk per paragraph, whatever the translations --
and every scaffolded chapter naming the chapter of the other session. The caps
do not compete: a chapter of the longest allowed length still proposes with
eight translations waiting for it. And no two items across both sessions share
nonce material, which is the one thing concurrency could actually get wrong;
seeding an item's nonce from its index instead is caught here and by
`SignedGroupKeyStateTest`, which already held the within-batch half of it.

Not covered: that `AddChapterViewModel` opens the second session, which is
plumbing across two dispatchers over templates these tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:03:03 +02:00
parent 359f54812f
commit 66119f34df
8 changed files with 255 additions and 8 deletions

View File

@@ -24,6 +24,21 @@ interface MantraTranslationArtifactVersionDao {
)
suspend fun getTranslationsByArtifactId(artifactId: String): List<MantraTranslationArtifactVersion>
/**
* 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<MantraTranslationArtifactVersion>
@Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id")
suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion?
}

View File

@@ -138,6 +138,9 @@ class DatabaseFrostSigningRepository(
}
}
override fun unsignedEvents(items: List<FrostSigningItem>): List<Event> =
items.map { Event.fromJson(it.unsignedEventJson) }
override fun signedEvents(items: List<FrostSigningItem>): List<Event> =
FrostSigningManager.signedEvents(items)

View File

@@ -73,6 +73,9 @@ class DatabaseMantraRepository(
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? =
database.mantraTranslationArtifactVersionDao().getTranslationById(id)
override suspend fun getTranslationsForArtifactVersion(artifactVersionId: String): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactVersionId(artifactVersionId)
override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? =
database.mantraTranslationChapterDao().getTranslationChapterById(id)

View File

@@ -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<FrostSigningItem>): List<Event>
/** The finished events, or empty while any of them is still running. */
fun signedEvents(items: List<FrostSigningItem>): List<Event>
@@ -168,6 +179,8 @@ interface FrostSigningRepository {
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override fun unsignedEvents(items: List<FrostSigningItem>): List<Event> = emptyList()
override fun signedEvents(items: List<FrostSigningItem>): List<Event> = emptyList()
}
}

View File

@@ -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<MantraTranslationArtifactVersion>
suspend fun getTranslationChapter(id: String): MantraTranslationChapter?
suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter>
@@ -105,6 +112,8 @@ interface MantraRepository {
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
override suspend fun getTranslationsForArtifactVersion(artifactVersionId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? = null
override suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter> = emptyList()

View File

@@ -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"} " +

View File

@@ -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"

View File

@@ -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<String>) =
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()