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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user