diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt index 0989e273..348d83d2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt @@ -95,14 +95,24 @@ data class MantraChunk( /** * 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. + * 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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt index 7c3043c9..2bad537f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt @@ -34,11 +34,12 @@ class ChapterEvent( * 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. + * 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. */ fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt index 08b295d1..05fe1d72 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt @@ -18,7 +18,7 @@ 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.FrostSigningItem import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.extensions.toHex @@ -108,24 +108,17 @@ class SignedChapterTest { 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(), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, 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()) + /** A quorum signing the item's event, in the manager's order. */ + private fun groupSignature(item: FrostSigningItem): String { + val message = ByteVector(item.eventId.hexToByteArray()) val signerIds = listOf(0, 1) val nonces = signerIds.map { signerId -> @@ -166,8 +159,8 @@ class SignedChapterTest { text: String = originalText, index: Int = 0, ): ChapterEvent { - val session = sessionOver(unsignedEventOf(proposalTemplate(name, text, index))) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate(name, text, index))) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) return ChapterEvent( signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig @@ -205,15 +198,15 @@ class SignedChapterTest { // 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 item = itemOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) val chapter = MantraChapter.fromChapterEvent( ChapterEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), chatRoomId ) - assertEquals(session.eventId, chapter?.id) + assertEquals(item.eventId, chapter?.id) } @Test diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index a55eb573..c2949117 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -487,6 +487,32 @@ one rule that a batch is only as available as its worst item, so events that do not belong together should not travel together, and the one prohibition that `GroupKeyStateManager.propose` must never batch. +### The first caller to weigh it, and decline + +Adding a chapter is the case batching looks made for: a chapter is proposed +with a chunk per paragraph, which before this work would have been one quorum +each. It signs the chapter alone anyway, and splits the chunks back out of the +signed text on every device +([`MantraChunk.chunksOf`](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt)). +Recorded here because the next caller will reach for the same shape: + +- **The cap binds on ordinary content.** `MAX_BATCH_SIZE` is 64, so a batched + chapter is capped at 63 paragraphs. Prose runs past that, and the failure is + a chapter that cannot be proposed at all. +- **The text would travel twice** — whole in the chapter, again split across + the chunks — and the proposal is the term the cap is sized against. +- **Availability falls with length.** All-or-nothing over `k` items means a + long chapter is less likely to get signed than a short one, for no reason a + member could see. +- **The signature would be redundant.** The appendix rejects the manifest shape + because an item then needs a lookup to be checked. Here the lookup is a + foreign key: a chunk is a pure function of its chapter and cannot be stored + without it, so the chapter's signature already covers it. + +The general rule this leaves behind: **batch siblings, not derivations.** Events +that could each have been authored separately are worth a batch; events that are +a function of another event in the same batch are worth deriving instead. + --- ## Appendix — what was considered and rejected