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:
Kgothatso Ngako
2026-09-06 10:34:12 +02:00
parent e081d14f37
commit 2e133dd337
15 changed files with 812 additions and 416 deletions

View File

@@ -9,8 +9,9 @@ kept as written -- they are the reasoning, and the code is easier to read
against the argument it came from than against a summary of itself. Where the
implementation chose differently from the first draft the section says so.
`FrostSigningManager.proposeSigningBatch` and
`FrostSigningRepository.proposeSigningBatch` are the entry points; nothing in
the app calls them yet, which is Phase 7's point.
`FrostSigningRepository.proposeSigningBatch` are the entry points. Adding a
chapter is the first caller, and needed a second form of them; see
[The first caller](#the-first-caller-a-chapter-and-its-chunks).
The headline: **there is no such thing as one FROST signature over many
messages, and no way to reuse a nonce across them.** What can be batched is the
@@ -487,31 +488,40 @@ 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
### The first caller: a chapter and its chunks
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:
Adding a chapter is the first thing in the app to batch. A chapter is proposed
together with a chunk per paragraph before this work, one quorum each, which
is why the chunks were briefly derived on arrival from the signed chapter's text
instead. They are signed now, and each carries the group's signature over its own
words: 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.
- **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.
Two things it needed that a flat list could not give:
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.
**An item that names another item.** A chunk carries the id of its chapter, and
that id is a hash over the group's key at the room's derivation path — neither
resolved until the proposal is made. A caller computing it would be recomputing
`signingPath`, the one input that must never come from a proposer. So
`proposeSigningBatch` has a second form taking a `lead` template and a
`dependents` builder, which is handed the lead *after* it is authored and returns
the events referring to it. Every id still comes from `unsignedEventOf`, and an
item naming a chapter nobody signed stops being a mistake that can be made.
**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 the thing being
referenced has to be signed first in the batch as well as named first.
**What it costs, in front of the user.** `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. `AddChapterScreen` shows the
chunk count against the cap as the text is typed and refuses to propose past it,
because the alternative is an `IllegalArgumentException` after the fact.
The general rule this leaves behind: **a batch is for events that arrive
together and are checked apart.** If the items are only ever read through one of
them, deriving is cheaper and has no cap; if each is something a member might
hold, hand it its own signature.
---