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:
Kgothatso Ngako
2026-09-06 10:13:09 +02:00
parent 60abf243ed
commit e081d14f37
4 changed files with 62 additions and 32 deletions

View File

@@ -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