feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the group signs the lot at once. Every row a member ends up with is signed: a translation is of a chunk, and a chunk that carries the group's signature over its own words can be checked by anybody holding it, rather than only by re-deriving it from the chapter it came out of. This replaces the derivation two commits ago, which split the chunks out of the signed chapter's text on each device and left them as rumors. That was the right shape when a chunk could only have its own signature by having its own quorum. Batch signing removed that, and this is the other side of the trade `MantraChunk.chunksOf` was weighed against. **A batch whose items name each other.** A chunk carries its chapter's id, and that id is a hash over the group's key at the room's derivation path -- neither resolved until the proposal runs. A caller computing it would be recomputing `signingPath`, the one input in this protocol that must never come from a proposer, since the path decides which key the group signs as. So `proposeSigningBatch` gains a second form: a `lead` template, and a `dependents` builder handed the lead *after* it is authored, returning the events that reference it. Every id still comes out of `unsignedEventOf`, which makes an item naming a chapter nobody signed something that cannot be built rather than something to be tested for. `AddChapterViewModel` passes `ChunkEvent::splitOf` and nothing else. The lead is item 0. Items apply in `itemIndex` order and a chunk row whose chapter does not exist yet is a foreign key violation, so what is referenced is signed first as well as named first. **The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the chapter takes one place, so a chapter is capped at 63 paragraphs and a longer one has to be split in two. That is a real limit on real prose. The form counts chunks against the cap as the text is typed, colours the count when it is past, says what to do about it, and will not propose -- because the alternative is an IllegalArgumentException after the fact. The manager still refuses independently; the screen is not what enforces it. **What went away.** `MantraChunk.chunksOf` and the derivation it did inside `ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent` still carries the whole text beside chunks that hold the same words: chunk boundaries are a decision about how to divide the work, and a chapter that kept only the pieces could never be divided differently again. **Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what each chunk names, counts and carries. `SignedChapterTest` signs a real batch, one FROST instance per item, and checks every chunk row is authored by the room and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the real proposal against a real database, which is where the sharp edge is: item order, the chunks naming the chapter as the group will author it, and both ends of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session behind. Checked against broken implementations: putting the lead last, naming the wrong chapter, and stamping the chunks off the clock are each caught, in both suites. `jvmTest` runs on linux again as of the merge, which is what made the database-backed test possible. Dropped a nonce-reuse test that was in the first draft of this: it asserted over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest` already hold the manager to giving every item its own nonce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -805,17 +805,19 @@ 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
|
||||
)
|
||||
|
||||
// The chunks the chapter splits into are their own events,
|
||||
// signed in the same session and applied after it -- see
|
||||
// AddChapterViewModel.addChapter. Nothing to do here but the
|
||||
// chapter itself.
|
||||
MantraChapter.fromChapterEvent(
|
||||
chapterEvent = chapterEvent,
|
||||
chapterEvent = ChapterEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraChapter ->
|
||||
database.mantraChapterDao().upsert(
|
||||
@@ -824,24 +826,6 @@ 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",
|
||||
|
||||
@@ -10,12 +10,10 @@ 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
|
||||
|
||||
@@ -92,65 +90,6 @@ 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. 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 could be signed. `FrostSigningManager.proposeSigningBatch` would
|
||||
* carry the chapter and a chunk per paragraph through one quorum, and
|
||||
* every row would then hold a signature of its own. It is not worth what
|
||||
* it costs: `MAX_BATCH_SIZE` is 64, which caps a chapter at 63
|
||||
* paragraphs and fails an ordinary one outright; the text would go on
|
||||
* the wire twice, whole on the chapter and again split across the
|
||||
* chunks; and a batch is only as available as its worst item, so a
|
||||
* chapter's odds of being signed would fall with its length. What the
|
||||
* signature would prove is proved already -- a chunk is a pure function
|
||||
* of the chapter it hangs off, and it cannot be held without that
|
||||
* chapter, which the foreign key enforces.
|
||||
*
|
||||
* 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,
|
||||
|
||||
@@ -81,6 +81,21 @@ class DatabaseFrostSigningRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
lead: EventTemplate<*>,
|
||||
dependents: (lead: Event) -> List<EventTemplate<*>>
|
||||
): FrostSigningSession? = proposing(localChatRoom) {
|
||||
FrostSigningManager.proposeSigningBatch(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
lead = lead,
|
||||
dependents = dependents
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposing throws when the group has no key, when this device was not in the
|
||||
* ceremony, or when a batch is empty or over the cap. All four are states the
|
||||
|
||||
@@ -192,12 +192,43 @@ object FrostSigningManager {
|
||||
userPublicKey: HexKey,
|
||||
events: List<EventTemplate<*>>,
|
||||
key: DkgSession? = null
|
||||
): FrostSigningSession {
|
||||
require(events.isNotEmpty()) { "A signing session must be given something to sign" }
|
||||
require(events.size <= MAX_BATCH_SIZE) {
|
||||
"A signing session will sign at most $MAX_BATCH_SIZE events, not ${events.size}"
|
||||
}
|
||||
): FrostSigningSession = proposeSigningBatch(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
lead = events.firstOrNull()
|
||||
?: throw IllegalArgumentException("A signing session must be given something to sign"),
|
||||
dependents = { events.drop(1) },
|
||||
key = key
|
||||
)
|
||||
|
||||
/**
|
||||
* Opens a session over an event and the events that name it.
|
||||
*
|
||||
* The flat form above cannot express this. A chunk carries the id of the
|
||||
* chapter it belongs to, and that id is not knowable to a caller: it is a
|
||||
* hash over the *group's* key at the *room's* derivation path, and neither
|
||||
* is resolved until this function runs. A caller that computed it anyway
|
||||
* would be recomputing [signingPath], which is the one input in this
|
||||
* protocol that must never come from a proposer -- the path decides which
|
||||
* key the group signs as.
|
||||
*
|
||||
* So [lead] is built here and handed to [dependents], which returns the
|
||||
* events that reference it. Every id in the batch then comes from one place,
|
||||
* and an item naming a chapter nobody signed is not a mistake that can be
|
||||
* made rather than one that has to be tested for.
|
||||
*
|
||||
* The lead is item 0, so it is applied before anything that names it -- a
|
||||
* chunk row whose chapter does not exist yet is a foreign key violation.
|
||||
*/
|
||||
suspend fun proposeSigningBatch(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
lead: EventTemplate<*>,
|
||||
dependents: (lead: Event) -> List<EventTemplate<*>>,
|
||||
key: DkgSession? = null
|
||||
): FrostSigningSession {
|
||||
val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
?: completedKey(database, localChatRoom.chatRoom.id)
|
||||
?: throw IllegalStateException("This group has no shared key to sign with")
|
||||
@@ -206,7 +237,7 @@ object FrostSigningManager {
|
||||
?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}")
|
||||
|
||||
val path = signingPath(database, localChatRoom, ceremony)
|
||||
val unsignedEvents = events.map { template ->
|
||||
val unsignedEventOf = { template: EventTemplate<*> ->
|
||||
unsignedEventOf(
|
||||
key = ceremony,
|
||||
path = path,
|
||||
@@ -216,6 +247,14 @@ object FrostSigningManager {
|
||||
createdAt = template.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
val leadEvent = unsignedEventOf(lead)
|
||||
val unsignedEvents = listOf(leadEvent) + dependents(leadEvent).map(unsignedEventOf)
|
||||
|
||||
require(unsignedEvents.size <= MAX_BATCH_SIZE) {
|
||||
"A signing session will sign at most $MAX_BATCH_SIZE events, not ${unsignedEvents.size}"
|
||||
}
|
||||
|
||||
val sessionId = RandomInstance.bytes(32).toHex()
|
||||
|
||||
val session = FrostSigningSession(
|
||||
|
||||
@@ -31,15 +31,13 @@ class ChapterEvent(
|
||||
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name
|
||||
|
||||
/**
|
||||
* The markdown the chapter is, and the only place its chunks come from.
|
||||
* The markdown the chapter is, whole.
|
||||
*
|
||||
* Carried on the chapter rather than in an event per paragraph because the
|
||||
* group signs the chapter and nothing else. Ids invented locally differ on
|
||||
* every device holding the same chapter, so the chunks 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. Signing them alongside the chapter as a batch is possible and
|
||||
* was weighed; MantraChunk.chunksOf says what it would cost.
|
||||
* The chapter carries its text even though the chunks cut from it are
|
||||
* signed alongside it and carry the same words between them. It is the
|
||||
* chapter as the group signed it: chunk boundaries are a decision about how
|
||||
* to divide the work, and a chapter that kept only the pieces could never be
|
||||
* divided again differently without losing what was agreed to.
|
||||
*/
|
||||
fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
@@ -12,6 +13,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
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
|
||||
|
||||
|
||||
@Immutable
|
||||
@@ -36,6 +38,43 @@ class ChunkEvent(
|
||||
const val KIND = 30303
|
||||
const val ALT_DESCRIPTION = "Chunk"
|
||||
|
||||
/**
|
||||
* The chunks [chapter]'s text splits into, ready to be signed with it.
|
||||
*
|
||||
* A chapter and its chunks go to the group as one batch, so these are
|
||||
* built from the chapter *after* it has been authored under the group's
|
||||
* key -- [chapter] is the unsigned event the session will sign, which is
|
||||
* where the id each chunk carries comes from. Splitting anywhere else
|
||||
* would mean naming a chapter id before one exists.
|
||||
*
|
||||
* They take the chapter's own timestamp, so the batch reads as one act
|
||||
* rather than as events that happen to share a session.
|
||||
*
|
||||
* Empty when the chapter has no text to split, which is a chapter with
|
||||
* nothing in it to translate.
|
||||
*/
|
||||
fun splitOf(chapter: Event): List<EventTemplate<ChunkEvent>> {
|
||||
val originalText = ChapterEvent(
|
||||
id = chapter.id,
|
||||
pubKey = chapter.pubKey,
|
||||
createdAt = chapter.createdAt,
|
||||
tags = chapter.tags,
|
||||
content = chapter.content,
|
||||
sig = chapter.sig
|
||||
).originalText() ?: return emptyList()
|
||||
|
||||
return Markdown.splitParagraphs(originalText).mapIndexed { index, paragraph ->
|
||||
build(
|
||||
chapterId = chapter.id,
|
||||
text = paragraph,
|
||||
index = index,
|
||||
wordCount = Markdown.wordCount(paragraph),
|
||||
characterCount = Markdown.characterCount(paragraph),
|
||||
createdAt = chapter.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(
|
||||
chapterId: String,
|
||||
text: String,
|
||||
|
||||
@@ -92,6 +92,27 @@ interface FrostSigningRepository {
|
||||
events: List<EventTemplate<*>>
|
||||
): FrostSigningSession?
|
||||
|
||||
/**
|
||||
* Opens a session over an event and the events that name it, for a batch
|
||||
* that is not a flat list: a chapter and its chunks, where each chunk
|
||||
* carries the chapter's id.
|
||||
*
|
||||
* That id cannot be worked out by a caller -- it is a hash over the group's
|
||||
* key at the room's derivation path, neither of which is resolved until the
|
||||
* proposal is made -- so [lead] is built first and handed to [dependents],
|
||||
* which returns the events referring to it. The lead is item 0 and so is
|
||||
* applied first, which is what anything holding a foreign key to it needs.
|
||||
*
|
||||
* Everything the flat form says still holds: all-or-nothing, only as
|
||||
* available as its worst item, and a retry is a new batch.
|
||||
*/
|
||||
suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
lead: EventTemplate<*>,
|
||||
dependents: (lead: Event) -> List<EventTemplate<*>>
|
||||
): FrostSigningSession?
|
||||
|
||||
/** Agrees to sign, letting the session publish this device's part and run on. */
|
||||
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
|
||||
|
||||
@@ -136,6 +157,13 @@ interface FrostSigningRepository {
|
||||
events: List<EventTemplate<*>>
|
||||
): FrostSigningSession? = null
|
||||
|
||||
override suspend fun proposeSigningBatch(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
lead: EventTemplate<*>,
|
||||
dependents: (lead: Event) -> List<EventTemplate<*>>
|
||||
): FrostSigningSession? = null
|
||||
|
||||
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
|
||||
|
||||
@@ -105,7 +105,17 @@ fun AddChapterScreen(
|
||||
// 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
|
||||
|
||||
// The chapter and a chunk per paragraph are signed in one session,
|
||||
// and a session signs a bounded number of events. Past that the
|
||||
// chapter has to be split in two, which is worth saying while there
|
||||
// is still a cursor in the text rather than after a failed propose.
|
||||
val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER
|
||||
|
||||
val canProposeChapter = artifactVersion != null &&
|
||||
addChapterUIState.canSign &&
|
||||
paragraphCount > 0 &&
|
||||
!tooManyChunks
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
@@ -147,7 +157,7 @@ fun AddChapterScreen(
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (artifactVersion == null || !addChapterUIState.canSign) {
|
||||
if (artifactVersion == null || !canProposeChapter) {
|
||||
return@ExtendedFloatingActionButton
|
||||
}
|
||||
|
||||
@@ -205,6 +215,14 @@ fun AddChapterScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else if (tooManyChunks) {
|
||||
Text(
|
||||
text = "$paragraphCount chunks is more than the group can sign in " +
|
||||
"one go. Split the chapter so that no part of it is over " +
|
||||
"${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -228,8 +246,18 @@ fun AddChapterScreen(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$wordCount words · $characterCount characters · $paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"}",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
// The chunk count is what the group is asked to sign
|
||||
// alongside the chapter, so it is a count of the work
|
||||
// being proposed rather than a curiosity about the text.
|
||||
text = "$wordCount words · $characterCount characters · " +
|
||||
"$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " +
|
||||
"of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (tooManyChunks) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium.color
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,8 +392,8 @@ private fun OneThingBeingSigned(event: Event) {
|
||||
// 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).
|
||||
// count says how many of the batch's other items came out of this
|
||||
// text, which is the rest of what is being signed.
|
||||
val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size }
|
||||
|
||||
listOfNotNull(
|
||||
|
||||
@@ -18,7 +18,9 @@ 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.managers.FrostSigningManager
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
@@ -63,21 +65,29 @@ class AddChapterViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign a chapter into the artifact.
|
||||
* Asks the group to sign a chapter, and every chunk it splits into, in one
|
||||
* session.
|
||||
*
|
||||
* 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.
|
||||
* Nothing is created here. What goes out is a proposal to sign the lot, and
|
||||
* the chapter and its chunks appear 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.
|
||||
*
|
||||
* The chunks travel as their own events rather than being split back out of
|
||||
* the signed chapter on each device, so that each one carries the group's
|
||||
* signature over its own text -- a translation is of a chunk, and a chunk
|
||||
* that can be checked on its own is worth more than one that can only be
|
||||
* checked by re-deriving it. What it costs is [MAX_CHUNKS_PER_CHAPTER]: the
|
||||
* batch is capped, the chapter takes one of its places, and a chapter with
|
||||
* more paragraphs than the rest cannot be proposed at all.
|
||||
*/
|
||||
fun addChapter(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -89,8 +99,12 @@ class AddChapterViewModel(
|
||||
) {
|
||||
val name = nameField.text.toString()
|
||||
val originalText = originalTextField.text.toString()
|
||||
val paragraphs = Markdown.splitParagraphs(originalText)
|
||||
|
||||
if (name.isBlank() || originalText.isBlank()) {
|
||||
// The cap is the group's, not this screen's, and proposing past it
|
||||
// throws rather than failing softly. The form says so as it is typed;
|
||||
// this is the check that has to hold when it does not.
|
||||
if (name.isBlank() || paragraphs.isEmpty() || paragraphs.size > MAX_CHUNKS_PER_CHAPTER) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
@@ -100,11 +114,6 @@ class AddChapterViewModel(
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// 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,
|
||||
@@ -119,12 +128,15 @@ class AddChapterViewModel(
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = chapterEventTemplate.kind,
|
||||
tags = chapterEventTemplate.tags,
|
||||
content = chapterEventTemplate.content,
|
||||
lead = chapterEventTemplate,
|
||||
// A chunk names the chapter it belongs to, and that id is a
|
||||
// hash over the group's key at the room's path -- neither of
|
||||
// which this screen knows or should. The chapter comes back
|
||||
// built, and the chunks are cut from the text it carries.
|
||||
dependents = ChunkEvent::splitOf
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a chapter for signing", error)
|
||||
@@ -150,6 +162,17 @@ class AddChapterViewModel(
|
||||
companion object {
|
||||
private const val TAG = "AddChapterViewModel"
|
||||
|
||||
/**
|
||||
* The most paragraphs a chapter can be proposed with.
|
||||
*
|
||||
* A chapter is signed together with a chunk per paragraph, and the
|
||||
* session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The
|
||||
* chapter is one of them, so the paragraphs get the rest. A longer
|
||||
* chapter has to be split into two, which is a real limit and belongs in
|
||||
* front of whoever is typing rather than in a failure afterwards.
|
||||
*/
|
||||
const val MAX_CHUNKS_PER_CHAPTER: Int = FrostSigningManager.MAX_BATCH_SIZE - 1
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
artifactId: String,
|
||||
|
||||
Reference in New Issue
Block a user