refactor: weigh the chapter's chunks against batch signing, and keep deriving
Batch signing landed on mantra while this branch was open, and it makes the argument this change was built on obsolete as written. MantraChunk.chunksOf said the chunks "cannot be events proposed on their own -- that would cost a quorum per paragraph". They can now: proposeSigningBatch would carry the chapter and a chunk per paragraph through one quorum, and every row would hold a signature of its own. Weighed and declined, and the KDoc now says so rather than leaning on a reason that stopped being true. MAX_BATCH_SIZE is 64, which caps a batched 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 the proposal is the term that cap is sized against; and all-or-nothing over k items would make a long chapter less likely to be signed than a short one, for no reason a member could see. The signature it would buy is redundant besides -- the appendix rejects the manifest shape because an item then needs a lookup to be checked, and here that lookup is a foreign key: a chunk is a pure function of its chapter and cannot be stored without it. docs/frost-batch-signing.md records this under the slot Phase 7 leaves open -- "deciding *what* to batch" -- because the next caller will reach for the same shape. The rule it 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. **The merge.** Only SignedChapterTest broke: the five per-item columns moved off FrostSigningSession onto FrostSigningItem, so it builds an item and calls signedEvent(item, sig), which is how SignedArtifactTest was ported in the same commit. Nothing in the flow itself moved -- proposeSigning kept its signature as the one-event form, and complete() applies each signed event through ChatMessage.applyInnerEvent, so the chapter's chunk derivation works the same whether the chapter arrives alone or as one item of somebody else's batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user