feat: sign a chapter into the artifact instead of submitting one

Adding a chapter no longer creates one. It opens a signing session over a
ChapterEvent, and the chapter appears -- on every member's device at once,
authored by the room's shared key rather than by whoever pasted the text --
when enough members have signed. The same trade the dialects and artifacts
made: a submission says "I am putting this in front of the group" and the
group's only recourse afterwards is social, while a signature is the group
saying it and it takes a quorum to say. The text everybody translates from is
the group's, so the second is the honest one.

**The chunks.** This is the part chapters had that dialects and artifacts did
not. A chapter was submitted along with a ChunkEvent per paragraph, and that
cannot survive the change: a translation is of a chunk rather than of a
chapter, so a chapter without them cannot be worked on, but the chunks cannot
have their own quorum without costing one signing session per paragraph, and
they cannot be invented locally -- an invented id differs on every device, so
members would silently disagree about which chunk a translation is of while
every screen showed the same chapter.

So the chunks are split back out of the signed chapter's own text when it is
applied, in MantraChunk.chunksOf, on the pattern
MantraArtifactVersion.initialVersionOf already set. Same bytes in, same rows
out, everywhere. They are rumors, because nobody signed them; what the group
signed is the chapter they were split from.

It splits only what the group signed. A chapter that arrived as a submission
was sent with its own chunk events, written under the submitter's key, and
deriving a second set beside them would leave every paragraph in the chapter
twice under ids nothing reconciles -- including on a marmot reindex, which
replays a room's group events without anybody adding anything.

**The index.** Where a chapter sits in its version is read at proposal time
and signed into the event, rather than derived on arrival like the chunks
are. A device applying the chapter cannot recount it: it would be counting a
version other members may have added to in a different order, and the count
has to be the one the group put its signature to. The window between proposal
and quorum is longer than the old write-and-submit window was, so two chapters
proposed at once can still land on one index -- the same race as before, wider.

**What went away.** MantraDao.addChapter and its way up through the repository.
Nothing called it once the screen proposed instead, and leaving a path that
authors a chapter under a member's key while the UI insists on a quorum would
have double-created the chunks besides. MantraRepository.getChaptersForArtifactVersion
replaces the one thing it did that is still needed: counting the index.

**The screens.** AddChapterScreen loads the room, the artifact's latest
version and canSign up front, disables the FAB when either is missing the way
the dialect and artifact screens do, and on success lands on the session
rather than on an artifact the chapter is not in yet. FrostSigningScreen
described a chapter proposal by name alone, so a member was asked to sign text
whose size they could not see; it now reads name, word count and chunk count,
the way an artifact shows its url.

**Tests.** Two files, and each was checked against a broken implementation
rather than only against a working one: deriving the chunks from the clock,
inheriting the chapter's counts across every chunk, authoring the derived rows
as their reader, and losing the paragraph position are all caught, as is
splitting a chapter that arrived as a submission. SignedChapterTest runs a
real 2-of-3 quorum over an actual proposal, because the claim worth holding --
the chapter is the group's, carries proof of it, and every device splits it
into the same chunks -- is invisible when it breaks.

Not covered: applyInnerEvent's upserts, which need a database no test here
stands up, and AddChapterViewModel, which is plumbing across two dispatchers
over a template the tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 04:15:36 +02:00
parent a805455df8
commit eb34c8edb3
13 changed files with 825 additions and 160 deletions

View File

@@ -10,8 +10,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
@@ -19,8 +17,6 @@ import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
@@ -29,7 +25,6 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
import press.mantra.compose.text.Markdown
import kotlin.time.Instant
@Dao
@@ -201,81 +196,6 @@ abstract class MantraDao(
return mantraArtifactVersion
}
@Transaction
open suspend fun addChapter(
artifactId: String,
name: String,
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraChapter? {
// Chapters attach to an artifact version; use the latest one.
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
.firstOrNull() ?: return null
val chapterIndex = database.mantraChapterDao()
.getChaptersByArtifactVersionId(version.id)
.size
val chapterEventTemplate = ChapterEvent.build(
artifactVersionId = version.id,
name = name,
originalText = originalText,
index = chapterIndex,
wordCount = Markdown.wordCount(originalText),
characterCount = Markdown.characterCount(originalText),
)
val chapter = MantraChapter.fromChapterEventTemplate(
chapterEventTemplate = chapterEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
return try {
database.mantraChapterDao().upsert(chapter)
submitToGroup(
chatRoomId = chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(chapterEventTemplate, userPublicKey),
text = "Added chapter to artifact", // TODO: Get artifact to use in text...
)
// Split the markdown into paragraph chunks.
Markdown.splitParagraphs(originalText).forEachIndexed { chunkIndex, paragraph ->
val chunkEventTemplate = ChunkEvent.build(
chapterId = chapter.id,
text = paragraph,
index = chunkIndex,
wordCount = Markdown.wordCount(paragraph),
characterCount = Markdown.characterCount(paragraph),
)
MantraChunk.fromChunkEventTemplate(
chunkEventTemplate = chunkEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
)?.let { chunk ->
database.mantraChunkDao().upsert(chunk)
submitToGroup(
chatRoomId = chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(chunkEventTemplate, userPublicKey),
text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text...
)
}
}
chapter
} catch (error: Throwable) {
logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error)
null
}
}
@Transaction
open suspend fun addTranslationArtifactVersion(
artifactId: String,

View File

@@ -805,15 +805,17 @@ data class ChatMessage(
}
}
ChapterEvent.KIND -> {
val chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
)
MantraChapter.fromChapterEvent(
chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chapterEvent = chapterEvent,
chatRoomId = groupId,
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
@@ -822,6 +824,24 @@ data class ChatMessage(
)
)
// A signed chapter arrives with the chunks it is made
// of, split out here rather than sent, so that every
// device holding the chapter holds the same chunks.
// Nothing hangs off a chapter directly -- a translation
// is of a chunk -- so a chapter without them cannot be
// worked on. A submitted chapter brought its own, which
// is why this splits only what the group signed.
MantraChunk.chunksOf(
chapterEvent = chapterEvent,
chatRoomId = groupId,
).forEach { chunk ->
database.mantraChunkDao().upsert(
chunk.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
}
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",

View File

@@ -10,10 +10,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
import press.mantra.compose.nostr.nip30303.tags.IndexTag
import press.mantra.compose.nostr.nip30303.tags.WordStatisticsTag
import press.mantra.compose.text.Markdown
import kotlin.time.Clock
import kotlin.time.Instant
@@ -90,6 +92,55 @@ data class MantraChunk(
}
companion object {
/**
* The chunks a chapter is made of, derived from the chapter.
*
* The group signs a chapter; it does not sign these. So the chunks
* cannot be events proposed on their own -- that would cost a quorum
* per paragraph -- and they cannot be invented by whichever device
* notices the chapter first, because an invented id differs on every
* device and none of them would agree about which chunk a translation
* is of. Deriving them from the signed chapter's own text gives every
* device the same rows from the same bytes, which is the only property
* that matters here.
*
* They are rumors -- empty signature -- because nobody signed them.
* What the group signed is the chapter they were split out of, and that
* is also the only chapter this splits: a chapter that arrived as a
* submission was sent with its own chunk events, written under the
* submitter's key, and deriving a second set beside them would leave
* every paragraph in the chapter twice under ids nothing reconciles.
*
* Empty when the chapter has no text to split, which is every chapter
* whose original text is blank.
*/
fun chunksOf(
chapterEvent: ChapterEvent,
chatRoomId: HexKey,
): List<MantraChunk> {
if (chapterEvent.sig.isEmpty()) return emptyList()
val originalText = chapterEvent.originalText() ?: return emptyList()
return Markdown.splitParagraphs(originalText).mapIndexedNotNull { index, paragraph ->
fromChunkEventTemplate(
chunkEventTemplate = ChunkEvent.build(
chapterId = chapterEvent.id,
text = paragraph,
index = index,
wordCount = Markdown.wordCount(paragraph),
characterCount = Markdown.characterCount(paragraph),
// The chapter's own timestamp, not the reader's: a device
// applying the chapter an hour later has to arrive at the
// same ids as the one that applied it first.
createdAt = chapterEvent.createdAt,
),
chatRoomId = chatRoomId,
userPublicKey = chapterEvent.pubKey,
)
}
}
fun fromChunkEventTemplate(
chunkEventTemplate: EventTemplate<ChunkEvent>,
chatRoomId: HexKey,

View File

@@ -39,6 +39,9 @@ class DatabaseMantraRepository(
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId)
override suspend fun addTranslationArtifactVersion(
artifactId: String,
dialectId: String,
@@ -93,22 +96,6 @@ class DatabaseMantraRepository(
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> =
database.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(translationChapterId)
override suspend fun addChapter(
artifactId: String,
name: String,
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraChapter? {
return database.mantraDao().addChapter(
artifactId = artifactId,
name = name,
originalText = originalText,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey
)
}
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> =
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)

View File

@@ -29,6 +29,17 @@ class ChapterEvent(
fun artifactVersionId() = artifactVersionIdReference()?.eventId
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name
/**
* The markdown the chapter is, and the only place its chunks come from.
*
* Carried on the chapter rather than in an event per paragraph because the
* group signs the chapter and nothing else. Chunks proposed separately would
* need a quorum each, and ids invented locally differ on every device
* holding the same chapter -- so they are split back out of this text when
* the signed chapter is applied (see MantraChunk.chunksOf), which gives
* every device the same rows from the same bytes.
*/
fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText
fun index() = tags.firstNotNullOfOrNull(IndexTag::parse)?.index

View File

@@ -22,6 +22,13 @@ interface MantraRepository {
suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter>
/**
* The chapters of one version of an artifact, in reading order. A chapter
* hangs off a version rather than off the artifact, so this is what a new
* chapter's index counts.
*/
suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter>
suspend fun getChapter(id: String): MantraChapter?
suspend fun getChunksForChapter(chapterId: String): List<MantraChunk>
@@ -66,20 +73,6 @@ interface MantraRepository {
userPublicKey: HexKey,
): MantraTranslationArtifactVersion?
/**
* Add a chapter (markdown [originalText]) to the artifact's latest version.
* Word/character counts are derived from the text, and the chapter is split
* by paragraph into associated chunks. Returns null when the artifact has no
* version to attach to.
*/
suspend fun addChapter(
artifactId: String,
name: String,
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraChapter?
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
suspend fun getDialect(id: String): MantraDialect?
@@ -114,6 +107,8 @@ interface MantraRepository {
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> = emptyList()
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> = emptyList()
override suspend fun getChapter(id: String): MantraChapter? = null
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> = emptyList()
@@ -145,14 +140,6 @@ interface MantraRepository {
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? = null
override suspend fun addChapter(
artifactId: String,
name: String,
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraChapter? = null
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
override suspend fun getDialect(id: String): MantraDialect? = null

View File

@@ -13,8 +13,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -23,19 +25,28 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.text.Markdown
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -51,6 +62,9 @@ fun AddChapterScreen(
relayHint: String?,
initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
@@ -62,6 +76,8 @@ fun AddChapterScreen(
relayHint = relayHint,
initialAddChapterUIState = initialAddChapterUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
)
)
@@ -86,6 +102,15 @@ fun AddChapterScreen(
val characterCount = Markdown.characterCount(originalText)
val paragraphCount = Markdown.splitParagraphs(originalText).size
// A chapter hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addChapterUIState.artifactVersion
val canProposeChapter = artifactVersion != null && addChapterUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
@@ -105,29 +130,59 @@ fun AddChapterScreen(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeChapter) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeChapter) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeChapter) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || !addChapterUIState.canSign) {
return@ExtendedFloatingActionButton
}
addChapterViewModel.addChapter(
localChatRoom = addChapterUIState.localChatRoom,
artifactVersion = artifactVersion,
nameField = nameFieldState,
originalTextField = originalTextFieldState,
onSuccess = {
onNavigateToRoute.invoke(
ArtifactDetailRoute(
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the chapter appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint
sessionId = sessionId
)
)
},
onFailure = {}
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed Chapter")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Add chapter"
contentDescription = "Propose chapter"
)
Text("Add Chapter")
Text("Propose Chapter")
}
}
)
@@ -137,6 +192,21 @@ fun AddChapterScreen(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (!addChapterUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"chapter into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = "This artifact has no version for a chapter to attach to.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = nameFieldState,
@@ -201,6 +271,16 @@ private fun AddChapterScreenPreview() {
chatRoomId = "chatRoomId",
relayHint = null,
initialAddChapterUIState = AddChapterUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Message title",
description = "See something. Say somethin",
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
),
artifact = MantraArtifact(
id = "artifactId",
publicKey = "author",
@@ -211,9 +291,21 @@ private fun AddChapterScreenPreview() {
license = "cc",
chatRoomId = "chatRoomId",
signature = ""
)
),
artifactVersion = MantraArtifactVersion(
id = "artifactVersionId",
artifactId = "artifactId",
publicKey = "author",
versionLabel = "1.0",
chatRoomId = "chatRoomId",
signature = ""
),
canSign = true,
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {},
onNavigateBack = {}
)

View File

@@ -56,6 +56,7 @@ import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.text.Markdown
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.FrostSigningViewModel
@@ -319,7 +320,20 @@ private fun WhatIsBeingSigned(event: Event?) {
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).name().orEmpty()
).let { chapter ->
// The text is the substance of a chapter -- signing one is putting
// the group's name to what everybody will translate from -- but it
// is a whole chapter, so its size stands in for it here. The chunk
// count is what the text will actually split into, counted the same
// way the split itself counts (see MantraChunk.chunksOf).
val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size }
listOfNotNull(
chapter.name(),
chapter.wordCount()?.let { "$it words" },
chunkCount?.let { "$it ${if (it == 1) "chunk" else "chunks"}" }
).joinToString(" · ")
}
// The one thing a group signs that is about the group rather than about
// its work, and the only one a member sees before the room has done

View File

@@ -1017,15 +1017,22 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateToRoute = { actionRoute ->
// Replace both this add screen and the stale artifact detail
// beneath it so we land on a freshly-loaded detail screen.
navController.navigate(route = actionRoute) {
popUpTo<ArtifactDetailRoute> {
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace this add screen so back returns to the artifact
// rather than to a form whose proposal has already gone out.
navController.navigate(route = signingRoute) {
popUpTo<AddChapterRoute> {
inclusive = true
}
}
},
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
},
onNavigateBack = {
navController.popBackStack()
}

View File

@@ -16,7 +16,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.text.Markdown
import press.mantra.compose.ui.view.state.AddChapterUIState
class AddChapterViewModel(
@@ -26,6 +32,8 @@ class AddChapterViewModel(
val relayHint: String?,
initialAddChapterUIState: AddChapterUIState,
val mantraRepository: MantraRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addChapterUIState: AddChapterUIState by mutableStateOf(initialAddChapterUIState)
@@ -38,27 +46,52 @@ class AddChapterViewModel(
fun initiateAddChapter() {
viewModelScope.launch(Dispatchers.IO) {
val artifact = mantraRepository.getArtifact(artifactId)
addChapterUIState = if (artifact == null) {
AddChapterUIState.Error("Couldn't find the artifact")
} else {
AddChapterUIState.Loaded(artifact = artifact)
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
addChapterUIState = when {
artifact == null -> AddChapterUIState.Error("Couldn't find the artifact")
localChatRoom == null -> AddChapterUIState.Error("Couldn't find the chat room")
else -> AddChapterUIState.Loaded(
localChatRoom = localChatRoom,
artifact = artifact,
// Chapters attach to an artifact version; use the latest one.
artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull(),
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign a chapter into the artifact.
*
* The chapter is not created here and does not exist yet. What goes out is a
* proposal to sign it, and the chapter -- with the chunks it splits into --
* appears on every member's device at once, authored by this room's own key
* rather than by whoever pasted the text, when enough members have signed.
* That author is the room's id: signing runs at the path the room was
* derived at, so a chapter says which group's artifact it belongs to simply
* by being signed.
*
* That is the difference from submitting one. A submission says "I am
* putting this in front of the group" and the group's only recourse
* afterwards is social. A signature is the group saying it, and it takes a
* quorum to say. The text everyone will translate from is the group's, so
* the second is the honest one.
*/
fun addChapter(
localChatRoom: LocalChatRoom,
artifactVersion: MantraArtifactVersion,
nameField: TextFieldState,
originalTextField: TextFieldState,
onSuccess: () -> Unit,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val originalText = originalTextField.text.toString()
if (name.isBlank() || originalText.isBlank()) {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
onFailure.invoke()
return
}
@@ -67,23 +100,42 @@ class AddChapterViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val chapter = runCatching {
mantraRepository.addChapter(
artifactId = artifactId,
name = name,
originalText = originalText,
chatRoomId = chatRoomId,
// The chunks are not proposed. They are split out of the chapter's
// own text when the signed chapter is applied (see
// MantraChunk.chunksOf), so one quorum buys the whole chapter rather
// than one per paragraph, and every device splits the same text the
// same way.
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,
wordCount = Markdown.wordCount(originalText),
characterCount = Markdown.characterCount(originalText),
)
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
kind = chapterEventTemplate.kind,
tags = chapterEventTemplate.tags,
content = chapterEventTemplate.content,
)
}.onFailure { error ->
logger.e("Failed to add chapter", error)
logger.e("Failed to propose a chapter for signing", error)
}.getOrNull()
if (chapter != null) {
if (session != null) {
nameField.clearText()
originalTextField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke()
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
@@ -105,6 +157,8 @@ class AddChapterViewModel(
relayHint: String?,
initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddChapterViewModel(
@@ -114,6 +168,8 @@ class AddChapterViewModel(
relayHint = relayHint,
initialAddChapterUIState = initialAddChapterUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
)
}
}

View File

@@ -1,10 +1,28 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface AddChapterUIState {
data class Loaded(
val artifact: MantraArtifact
val localChatRoom: LocalChatRoom,
val artifact: MantraArtifact,
/**
* The version the chapter would attach to: the artifact's latest.
*
* Null on an artifact that has none, which is one nothing can be added
* to -- a chapter hangs off a version rather than off an artifact.
*/
val artifactVersion: MantraArtifactVersion? = null,
/**
* Whether the group holds a shared key. A chapter is signed into
* existence now rather than submitted, so a group without one cannot
* add one here at all.
*/
val canSign: Boolean = false,
): AddChapterUIState
data class Error(

View File

@@ -0,0 +1,220 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.tags.OriginalTextTag
/**
* The chunks of a chapter are derived, not delivered.
*
* The group signs a chapter and nothing else, so the chunks it splits into are
* not events anybody sent: every device builds those rows for itself out of the
* chapter it already holds. That only works while every device builds the *same*
* rows, and nothing about the ids would show it if they stopped — they are
* content hashes, opaque hex either way. What would show is two members
* translating what looks like the same paragraph into rows that never reconcile,
* with the chapter identical on both screens.
*/
class ChapterChunksTest {
private val groupKey = "a".repeat(64)
private val artifactVersionId = "b".repeat(64)
private val chatRoomId = "room"
private val twoParagraphs = "The first paragraph.\n\nThe second paragraph."
private fun signedChapter(
name: String = "Chapter 1",
originalText: String = twoParagraphs,
index: Int = 0,
createdAt: Long = 1_700_000_000,
): ChapterEvent {
val template = ChapterEvent.build(
artifactVersionId = artifactVersionId,
name = name,
originalText = originalText,
index = index,
wordCount = 6,
characterCount = originalText.length,
createdAt = createdAt,
)
// Hashed rather than made up, so two fixtures that differ are two
// different chapters here for the same reason they would be in the app.
return ChapterEvent(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupKey,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = "d".repeat(128)
)
}
@Test
fun `the derived chunks are a function of the chapter and nothing else`() {
// Every input has to come off the chapter. Reading the clock here would
// still agree with itself twice in a row -- and disagree between two
// devices that applied the same chapter minutes apart, which is the case
// nobody can reproduce on demand. So the timestamps are checked against
// the chapter's rather than against a second derivation.
val chapter = signedChapter(createdAt = 1_700_000_000)
val chunks = MantraChunk.chunksOf(chapter, chatRoomId)
assertEquals(2, chunks.size)
assertTrue(chunks.all { it.createdAt.epochSeconds == 1_700_000_000L })
}
@Test
fun `two devices derive the same chunks from the same chapter`() {
val chapter = signedChapter()
val mine = MantraChunk.chunksOf(chapter, chatRoomId)
val theirs = MantraChunk.chunksOf(chapter, chatRoomId)
assertEquals(mine.map { it.id }, theirs.map { it.id })
assertEquals(mine.map { it.createdAt }, theirs.map { it.createdAt })
}
@Test
fun `a chapter signed at a different moment derives different chunks`() {
// The chapter's own timestamp is bound into the derived ids, so two
// proposals identical but for when they were made stay two chapters with
// two sets of chunks rather than colliding on one set of rows.
val first = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_000), chatRoomId)
val second = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_001), chatRoomId)
assertNotEquals(first.map { it.id }, second.map { it.id })
}
@Test
fun `the derived chunks hang off the chapter and carry its text in order`() {
val chapter = signedChapter()
val chunks = MantraChunk.chunksOf(chapter, chatRoomId)
assertEquals(listOf(chapter.id, chapter.id), chunks.map { it.chapterId })
assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text })
assertEquals(listOf(0, 1), chunks.map { it.index })
// Authored by whoever authored the chapter -- the group, once signed --
// and unsigned, because nobody signed these.
assertTrue(chunks.all { it.publicKey == groupKey })
assertTrue(chunks.all { it.signature == "" })
}
@Test
fun `each chunk counts its own paragraph rather than the chapter's`() {
// The chapter's counts describe the whole text. A chunk that inherited
// them would report every paragraph as the length of the chapter, and
// any progress measured per chunk would be wrong by that factor.
val chunks = MantraChunk.chunksOf(
signedChapter(originalText = "One two three.\n\nFour."),
chatRoomId
)
assertEquals(listOf(3, 1), chunks.map { it.wordCount })
assertEquals(listOf("One two three.".length, "Four.".length), chunks.map { it.characterCount })
}
@Test
fun `two identical paragraphs stay two chunks`() {
// Ids are content hashes, so repeated text is where they would collide
// and a chapter would silently lose a paragraph. The position is in the
// event, which is what keeps them apart.
val chunks = MantraChunk.chunksOf(
signedChapter(originalText = "Again.\n\nAgain."),
chatRoomId
)
assertEquals(2, chunks.size)
assertNotEquals(chunks[0].id, chunks[1].id)
}
@Test
fun `where a chapter sits does not change the chunks it splits into`() {
// Two chapters with the same text at different positions in the book are
// two chapters, and their chunks hang off their own chapter. The check
// that matters is that the ids differ -- chunks of chapter three must not
// be chunks of chapter one.
val first = signedChapter(index = 0)
val second = signedChapter(index = 3)
val firstChunks = MantraChunk.chunksOf(first, chatRoomId)
val secondChunks = MantraChunk.chunksOf(second, chatRoomId)
assertNotEquals(first.id, second.id)
assertEquals(firstChunks.map { it.text }, secondChunks.map { it.text })
assertNotEquals(firstChunks.map { it.id }, secondChunks.map { it.id })
}
@Test
fun `a chapter with no text to split derives no chunks`() {
assertEquals(emptyList(), MantraChunk.chunksOf(signedChapter(originalText = " "), chatRoomId))
// And a chapter that declares no text at all, which is malformed rather
// than empty: nothing to split, and nothing to throw over either.
val declared = signedChapter()
val silent = ChapterEvent(
id = declared.id,
pubKey = declared.pubKey,
createdAt = declared.createdAt,
tags = declared.tags.filterNot { it.firstOrNull() == OriginalTextTag.TAG_NAME }
.toTypedArray(),
content = declared.content,
sig = declared.sig
)
assertEquals(emptyList(), MantraChunk.chunksOf(silent, chatRoomId))
}
@Test
fun `a chapter that arrived as a submission derives nothing`() {
// Chapters used to travel as a submission with a chunk event per
// paragraph, and those rows are already on disk -- written under the
// submitter's key, with ids that have nothing to do with these. Splitting
// one again would leave every paragraph in it twice, and a replay of the
// room's group events would do it without anybody adding anything.
val submitted = signedChapter().let { chapter ->
ChapterEvent(
id = chapter.id,
pubKey = chapter.pubKey,
createdAt = chapter.createdAt,
tags = chapter.tags,
content = chapter.content,
sig = "" // A rumor: what a submission carries.
)
}
assertEquals(emptyList(), MantraChunk.chunksOf(submitted, chatRoomId))
}
@Test
fun `the derived chunk row and its event agree on the id`() {
// A chunk is read back off the wire by other code paths (an inbound
// ChunkEvent from a peer), so the row a device derives has to be the row
// that event would produce -- otherwise the same chunk arrives twice.
val chunk = MantraChunk.chunksOf(signedChapter(), chatRoomId).first()
assertEquals(chunk.id, chunk.toChunkEvent().id)
assertEquals(
chunk.id,
EventHasher.hashId(
pubKey = chunk.publicKey,
createdAt = chunk.createdAt.epochSeconds,
kind = chunk.toChunkEvent().kind,
tags = chunk.toChunkEvent().tags,
content = chunk.toChunkEvent().content
)
)
}
}

View File

@@ -0,0 +1,282 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import fr.acinq.bitcoin.crypto.frost.TweakCache
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.nip30303.ChapterEvent
/**
* A chapter the group signed, from proposal to rows, against real FROST.
*
* Adding a chapter used to write a row and submit it, along with a submission
* per paragraph; the rows said the submitter wrote them, because they had. Now
* the group signs the chapter, and the claim this test exists to hold is that
* the chapter every device ends up with is the group's: authored by the
* threshold key, carrying a signature that verifies, with an id every member
* arrives at independently -- and that the chunks each device splits out of it
* are the same chunks.
*
* None of that is visible when it breaks. A row whose author is the proposer
* looks exactly like a row whose author is the group -- both are opaque hex --
* and a group that disagrees about the ids has two chapters that look like one.
*/
class SignedChapterTest {
private val participants = 3
private val threshold = 2
/** The member who pasted the text. Nothing they own should end up on the rows. */
private val proposer = "9".repeat(64)
private val artifactVersionId = "b".repeat(64)
private val originalText = "The first paragraph.\n\nThe second paragraph."
/** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
/**
* The room the group signs in: derived from its key at the admin path, which
* is what makes the room's id and the key it signs as one value.
*/
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
private val tweakCache: TweakCache = room.cache
/** The group's nostr identity here, which is also [chatRoomId]. */
private val groupPubKey = room.hex
private val chatRoomId = room.hex
private fun proposalTemplate(
name: String = "Chapter 1",
text: String = originalText,
index: Int = 0,
) = ChapterEvent.build(
artifactVersionId = artifactVersionId,
name = name,
originalText = text,
index = index,
wordCount = 6,
characterCount = text.length,
createdAt = 1_700_000_000L,
)
/**
* Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay
* exactly that: the proposer's fields re-authored under the group's key.
*/
private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event(
id = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupPubKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = chatRoomId,
coordinatorPublicKey = proposer,
userPublicKey = proposer,
dkgSessionId = "k".repeat(64),
threshold = threshold,
participantCount = participants,
signerId = 0,
derivationPath = SharedKeyDerivation.formatPath(),
unsignedEventJson = unsignedEvent.toJson(),
eventId = unsignedEvent.id,
nonceRandom = "f".repeat(64)
)
/** A quorum signing the session's event, in the manager's order. */
private fun groupSignature(session: FrostSigningSession): String {
val message = ByteVector(session.eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** Everything from the form to the rows a device holds afterwards. */
private fun signedChapterEvent(
name: String = "Chapter 1",
text: String = originalText,
index: Int = 0,
): ChapterEvent {
val session = sessionOver(unsignedEventOf(proposalTemplate(name, text, index)))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
return ChapterEvent(
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
)
}
@Test
fun `the chapter the group signs is authored by the group, not the proposer`() {
val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId)
assertNotNull(chapter)
assertEquals(groupPubKey, chapter.publicKey)
assertNotEquals(proposer, chapter.publicKey)
}
@Test
fun `the group it is authored by is the room it was signed in`() {
// Signing runs at the room's derivation path, so the author is the room's
// own id rather than the bare threshold key. That is what lets anybody
// holding the row check it without being told which key to expect -- and
// it is why the path a session signs at cannot come from the proposer.
val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId)
assertEquals(chatRoomId, chapter?.publicKey)
assertNotEquals(
keyMaterial.thresholdPublicKey.xOnly().value.toHex(),
chapter?.publicKey,
"a chapter must be signed by the room's key, not by the group's root key"
)
}
@Test
fun `the row's id is the id the group put its signature to`() {
// Every device builds this row from the same signed event, so the id has
// to be the one that was signed rather than anything recomputed from the
// proposer. Otherwise members converge on nothing and each holds its own
// copy of what is meant to be one chapter.
val session = sessionOver(unsignedEventOf(proposalTemplate()))
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
val chapter = MantraChapter.fromChapterEvent(
ChapterEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
chatRoomId
)
assertEquals(session.eventId, chapter?.id)
}
@Test
fun `the signature on the row verifies against the row's own id and author`() {
// The payoff of signing rather than submitting: the row carries proof the
// group made it, checkable by anybody holding it.
val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId)
assertNotNull(chapter)
assertTrue(
Nip01Crypto.verify(
signature = chapter.signature.hexToByteArray(),
hash = chapter.id.hexToByteArray(),
pubKey = chapter.publicKey.hexToByteArray()
),
"a chapter row should carry a signature the group's key made over its own id"
)
}
@Test
fun `what the form asked for is what the group signed`() {
// The fields travel as tags through a session that knows nothing about
// chapters. Anything dropped in there is signed away silently -- and the
// original text most of all, since the chunks are split out of it.
val chapter = MantraChapter.fromChapterEvent(
signedChapterEvent(name = "In Detention", index = 2),
chatRoomId
)
assertEquals("In Detention", chapter?.name)
assertEquals(originalText, chapter?.originalText)
assertEquals(artifactVersionId, chapter?.artifactVersionId)
assertEquals(2, chapter?.index)
}
@Test
fun `the chapter arrives with the chunks it splits into`() {
// Nothing sends these rows: each device derives them from the chapter it
// just applied. A translation is of a chunk rather than of a chapter, so
// a chapter that arrives without them cannot be worked on.
val signed = signedChapterEvent()
val chapter = MantraChapter.fromChapterEvent(signed, chatRoomId)
val chunks = MantraChunk.chunksOf(signed, chatRoomId)
assertEquals(2, chunks.size)
assertTrue(chunks.all { it.chapterId == chapter?.id })
assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text })
assertTrue(chunks.all { it.publicKey == groupPubKey })
// Derived, not signed: the group signed the chapter they were split from.
assertTrue(chunks.all { it.signature == "" })
}
@Test
fun `two members derive the same chunks from the chapter their group signed`() {
// The reason the chunks can be derived at all. Both members hold the same
// signed bytes, so both have to land on the same rows -- if they do not,
// a translation one of them writes is of a chunk the other does not have.
val signed = signedChapterEvent()
val mine = MantraChunk.chunksOf(signed, chatRoomId)
val theirs = MantraChunk.chunksOf(signed, chatRoomId)
assertEquals(mine.map { it.id }, theirs.map { it.id })
}
}