diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt new file mode 100644 index 00000000..bc53680a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt @@ -0,0 +1,127 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository + +/** + * Asking the group to scaffold newly proposed chapters into every translation + * of their version. + * + * A translation chapter carries no words -- it is `(translation, chapter, + * position)` -- and it exists so a translated chunk has somewhere to hang. See + * [TranslationScaffold] for the rows themselves; this is the part that reads + * the version, finds the chapters as the group will author them, and opens the + * sessions. + * + * **Its own session, not more items on the chapter's.** A chapter is capped at + * [TranslationScaffold] -- really at `FrostSigningManager.MAX_BATCH_SIZE` minus + * the chapter itself -- 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 did the second. + * + * **But one session for all of them, not one per chapter.** This is what the + * upload needed and what `AddChapterViewModel` had wrong for a single chapter + * only by not noticing: twelve chapters in a group with three dialects is + * thirty-six join rows, which fit in one batch, and the per-chapter shape would + * have opened twelve sessions to carry three rows each. Twenty-four proposals + * where thirteen will do. + * + * **Failure is logged and swallowed**, as it was before. The chapters have + * already been proposed and are the thing that was asked for; losing them + * because their scaffolding could not be opened would be the wrong way round. A + * join row naming a chapter that never reached a quorum fails a foreign key on + * the way in, is logged by `applySignedEvent`, and never becomes a row -- so the + * catch-up on the translation is what fills the gap afterwards. + */ +object ChapterScaffolding { + + private const val TAG = "ChapterScaffolding" + + /** + * A chapter that has just been put to the group, named by the session that + * will sign it. + * + * The session rather than the chapter, because there is no chapter yet. Its + * id is settled at proposal time -- it is the hash the signers are putting + * their shares behind -- and [FrostSigningRepository.unsignedEvents] is how + * to read it while the session is still running. + */ + data class ProposedChapter( + val sessionId: String, + val index: Int, + ) + + /** + * Opens as few sessions as will carry the join rows for [chapters]. + * + * Chunked over the **cross product**, not over the translations. With one + * chapter the two are the same and the old code was right by coincidence; + * with twelve, chunking the translations would propose twelve chapters' + * worth of rows in one batch per sixty-four dialects, which is the wrong + * axis and overflows at the fifth chapter of a three-dialect group. + * + * Returns the sessions opened, which is nothing the caller has to act on -- + * it is there so a test can count them. + */ + suspend fun propose( + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + artifactVersionId: String, + chapters: List, + ): List { + if (chapters.isEmpty()) return emptyList() + + return runCatching { + val translations = mantraRepository + .getTranslationsForArtifactVersion(artifactVersionId) + .ifEmpty { return emptyList() } + + // The chapters as the group will author them. A session whose lead + // event cannot be read is left out rather than guessed at: a join + // row naming the wrong chapter is worse than one that is missing, + // because the catch-up can find a missing one. + val sources = chapters.mapNotNull { proposed -> + val lead = frostSigningRepository + .unsignedEvents(frostSigningRepository.getItems(proposed.sessionId)) + .firstOrNull() + ?: return@mapNotNull null + + TranslationScaffold.SourceChapter(id = lead.id, index = proposed.index) to + lead.createdAt + } + if (sources.isEmpty()) return emptyList() + + // One timestamp for the lot, so a scaffolding proposed as one act + // reads as one. The latest of the chapters' own, which is the moment + // the act finished -- not the clock, which would be a third time + // unrelated to either end of it. + val createdAt = sources.maxOf { it.second } + + val rows = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = translations.map { it.id }, + chapters = sources.map { it.first }, + createdAt = createdAt, + ) + + rows.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = batch, + )?.id + } + }.onFailure { error -> + Logger.withTag(TAG).e( + "Proposed ${chapters.size} chapter(s) but could not ask for them in this " + + "version's translations", + error, + ) + }.getOrDefault(emptyList()) + } +} 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 fc529e3f..bea91925 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,8 +19,8 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.ChapterScaffolding 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.ChapterProposal import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -198,36 +198,18 @@ class AddChapterViewModel( /** * Asks the group to scaffold this chapter into every translation of the - * version, in sessions of their own. + * version, in a session of its 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. + * The reasoning and the failure policy are [ChapterScaffolding]'s; this is + * the one-chapter call into it. Sharing that code with the upload screen is + * the point -- there is one answer to what a chapter's scaffolding is, and + * two copies of a cross product would be two chances to disagree about what + * a translation covers. * - * 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. + * Called before navigating, not after: the route this screen sits on is + * popped on success, which clears this view model and takes + * `viewModelScope` with it, so work launched afterwards would be cancelled + * somewhere in the middle. */ private suspend fun scaffoldIntoTranslations( localChatRoom: LocalChatRoom, @@ -235,40 +217,19 @@ class AddChapterViewModel( 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, - ) + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = listOf( + ChapterScaffolding.ProposedChapter( + sessionId = chapterSessionId, + index = chapterIndex, ) - } - }.onFailure { error -> - logger.e( - "Proposed the chapter but could not ask for it in this version's translations", - error - ) - } + ), + ) } companion object { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt index 6ea233e5..5558c7c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -18,6 +18,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.files.PickedFile import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.ChapterScaffolding import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -208,6 +209,7 @@ class UploadChaptersViewModel( var index = nextIndex(artifactVersion) var proposed = 0 var failed = 0 + val scaffolded = mutableListOf() for (id in queue) { val row = (_uiState.value as? UploadChaptersUIState.Loaded) @@ -239,6 +241,10 @@ class UploadChaptersViewModel( if (session != null) { markProposed(id, session.id) + scaffolded += ChapterScaffolding.ProposedChapter( + sessionId = session.id, + index = index, + ) proposed++ index++ } else { @@ -247,6 +253,23 @@ class UploadChaptersViewModel( } } + // One batch for every chapter that went, not one per chapter. Twelve + // chapters in a three-dialect group is thirty-six join rows, which + // fit in a single session; the per-chapter shape would have opened + // twelve of them to carry three rows each. + // + // After the loop rather than inside it, so a chapter that failed to + // propose contributes no scaffolding and the ones that succeeded + // still get theirs. + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = scaffolded, + ) + passes++ updateLoaded { it.copy( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt new file mode 100644 index 00000000..e1156ff6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt @@ -0,0 +1,330 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +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.TranslationChapterEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown + +/** + * How many sessions a group is asked for when chapters arrive together. + * + * The scaffolding has to be its own session -- a chapter's paragraph budget + * should not be spent on the group's dialects -- but it does not have to be one + * session *per chapter*, and that only becomes visible above one chapter. The + * first test here is the arithmetic the upload rests on: twelve chapters and + * three dialects is thirty-six join rows, which is one batch rather than + * twelve. + */ +class ChapterScaffoldingJvmTest { + private val participants = 3 + private val threshold = 2 + + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold, + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + private lateinit var mantraRepository: MantraRepository + private lateinit var frostSigningRepository: FrostSigningRepository + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + mantraRepository = DatabaseMantraRepository(db, scope) + frostSigningRepository = DatabaseFrostSigningRepository(db, scope) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null, + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }, + ) + ) + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index], + ) + ) + } + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + chatRoomId = roomId, + signature = "", + ) + ) + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private suspend fun addTranslations(count: Int) { + (0 until count).forEach { ordinal -> + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "translation$ordinal".padEnd(64, '0'), + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Translation $ordinal", + visibility = "private", + license = "cc", + signature = "", + chatRoomId = roomId, + ) + ) + } + } + + /** Proposes [count] chapters the way the upload screen does, in index order. */ + private suspend fun proposeChapters(count: Int): List = + (0 until count).map { index -> + val originalText = "Paragraph one.\n\nParagraph two." + val session = FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = "Chapter $index", + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ), + dependents = ChunkEvent::splitOf, + ) + ChapterScaffolding.ProposedChapter(sessionId = session.id, index = index) + } + + private suspend fun scaffold(chapters: List) = + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = room, + userPublicKey = proposer, + artifactVersionId = artifactVersionId, + chapters = chapters, + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + @Test + fun `twelve chapters and three dialects is one scaffolding session`() = runBlocking { + openDevice() + addTranslations(3) + + val sessions = scaffold(proposeChapters(12)) + + // The arithmetic the upload rests on. Thirty-six join rows fit in one + // batch; a session per chapter would have been twelve of them carrying + // three rows each. + assertEquals(1, sessions.size) + assertEquals(36, itemEvents(sessions.single()).size) + } + + @Test + fun `every row is a translation chapter naming a chapter the group will author`() = + runBlocking { + openDevice() + addTranslations(2) + + val chapters = proposeChapters(2) + val sessions = scaffold(chapters) + val rows = itemEvents(sessions.single()) + + assertTrue(rows.all { it.kind == TranslationChapterEvent.KIND }) + + // The ids come from the chapters' own sessions, which is the only + // place they exist before a quorum signs: a hash over the group's + // key at the room's path. + val chapterIds = chapters.map { proposed -> + itemEvents(proposed.sessionId).first().id + } + val named = rows.map { row -> + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).chapterId() + } + + assertEquals(chapterIds.toSet(), named.toSet()) + } + + @Test + fun `a chapter keeps its own position, not the list's`() = runBlocking { + openDevice() + addTranslations(1) + + // A catch-up proposes a subset, so counting the list would renumber the + // chapters it happens to be filling in. + val sessions = scaffold( + listOf( + ChapterScaffolding.ProposedChapter( + sessionId = proposeChapters(1).single().sessionId, + index = 7, + ) + ) + ) + + val row = itemEvents(sessions.single()).single() + assertEquals( + 7, + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).index(), + ) + } + + @Test + fun `the cross product is what gets chunked, not the translations`() = runBlocking { + openDevice() + addTranslations(3) + + // 25 chapters x 3 dialects is 75 rows, which is two batches. Chunking + // the translations instead would have put all 75 in one and thrown -- + // which is the bug this generalisation fixes, and it only appears above + // one chapter. + val sessions = scaffold(proposeChapters(25)) + + assertEquals(2, sessions.size) + assertEquals( + FrostSigningManager.MAX_BATCH_SIZE, + itemEvents(sessions.first()).size, + ) + assertEquals(75 - FrostSigningManager.MAX_BATCH_SIZE, itemEvents(sessions.last()).size) + } + + @Test + fun `a version with no translations is asked for nothing`() = runBlocking { + openDevice() + + assertEquals(emptyList(), scaffold(proposeChapters(3))) + } + + @Test + fun `no chapters is asked for nothing`() = runBlocking { + openDevice() + addTranslations(3) + + assertEquals(emptyList(), scaffold(emptyList())) + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index ec61cabe..eee3aa51 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -557,14 +557,32 @@ during an upload. `TranslationScaffold.chaptersOf(translationArtifactVersionIds, chapters, createdAt)` already takes a list and already builds the full cross product in translation-then-reading order. So lift the scaffolding out of the per-chapter -loop: collect each chapter's `SourceChapter(id, index)` from its session's -unsigned lead event, and after the last chapter is proposed, propose the whole -cross product in `chunked(MAX_BATCH_SIZE)` batches — which is what `addChapter` -already does across *translations* and what -`TranslationArtifactVersionDetailViewModel` does across chapters. +loop into `ChapterScaffolding.propose`, which both view models now call: +collect each chapter's `SourceChapter(id, index)` from its session's unsigned +lead event, and after the last chapter is proposed, propose the whole cross +product in `chunked(MAX_BATCH_SIZE)` batches. Twelve chapters × three dialects: from 12 scaffolding sessions to 1. +**And it fixes a latent bug that one chapter could never show.** The old code +chunked the *translations* — right by coincidence at one chapter, where the +cross product and the translation list are the same length, and wrong at any +more: 25 chapters in a three-dialect group is 75 rows, which the translation +axis would have put in a single batch of 75 and `proposeSigningBatch` would have +thrown. Chunking the product is the generalisation, and the test for it is the +only one here that would fail against the per-chapter shape. + +Two smaller decisions the build had to make: + +- **One timestamp for the batch, the latest of the chapters' own.** + `chaptersOf` stamps every row with one `createdAt` so a scaffolding proposed + as one act reads as one; with N chapters there are N candidates. The latest is + the moment the act finished. Reading the clock instead would be a third time + unrelated to either end of it. +- **A session whose lead event cannot be read is left out, not guessed at.** A + join row naming the wrong chapter is worse than a missing one, because the + catch-up can find a missing one. + Keep the existing failure policy verbatim, including its reasoning: scaffolding failure is logged and swallowed, because the chapters are the thing that was asked for, and a scaffolding row naming a chapter that never reached quorum @@ -575,7 +593,15 @@ only as available as its worst item, and some of those chapters may genuinely fail. `AddChapterViewModel` keeps its one-chapter call into the same helper, so there -is one piece of code deciding what a chapter's scaffolding is. +is one piece of code deciding what a chapter's scaffolding is. The upload calls +it once, after its loop, with only the chapters that actually went — so a +chapter that failed to propose contributes no scaffolding and the ones that +succeeded still get theirs. + +**Tests:** `ChapterScaffoldingJvmTest`, six of them, over a real database and +real proposals. The arithmetic (12 × 3 = one session of 36 rows), the ids coming +from the chapters' own sessions, a chapter keeping its own index rather than the +list's, the cross-product chunking at 25 × 3 = 75, and the two empty cases. ---