feat(frost): a signing proposal has a size, and the form says what it is

Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today.

MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how
large they are, and those are not the same limit: sixty-four dialects are a
few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is
the largest message in this protocol by an order of magnitude -- a signer's
nonce payload is k*133 bytes, their partial k*65, the proposal k whole events
-- so a legal batch could exceed what a group event carries, and when it did
the failure was silent: the proposal went out and the session stalled with
nothing to blame.

MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way:
when proposing, against the real encoding, and independently in
acceptProposal, before parsing. That second check is the one that matters,
for the reason the count check already gives -- the proposal is where a
remote party decides how much work everybody else does.

The chapter form needed the same limit early enough to be useful, which the
exact answer cannot be: it needs the chapter built and the chunks cut, one
line from opening a session. So ChapterProposal estimates it. Measured over
chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an
encoded proposal runs 357-388 bytes; the estimate pays 448, because
over-estimating costs an author a shorter chapter and under-estimating costs
the group a stalled session. A test asserts it never falls under the real
encoding and stays within a tenth of it.

The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says
that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a
10-chunk chapter about 30, because the envelopes are what varies. The chunk
count is known as the text is typed, so the limit is too.

While the screen was open, its last two hardcoded English literals moved to
the catalogue, and the too-long message compares like with like -- what
proposing would send against what a group message carries -- rather than a
UTF-16 character count against a byte budget, which is what the first draft
of it did.

944 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 18:41:20 +02:00
parent eb191d945a
commit dbc7940618
8 changed files with 495 additions and 53 deletions

View File

@@ -289,36 +289,50 @@ title; `03 - The Wreck.md` is.
**Half a day. A fix to the path that exists, and a prerequisite for Phase 4.**
A chapter whose markdown is over roughly 28 KiB cannot be proposed today and
A chapter whose markdown is over roughly 24 KiB cannot be proposed today and
nothing says so. Make the limit explicit and check it where the paragraph cap is
already checked.
```kotlin
// ChapterEvent companion, beside build()
/**
* The most markdown a chapter may carry.
*
* A chapter is proposed with a chunk per paragraph, so the proposal carries its
* text roughly twice -- whole in the chapter's `original_text` tag, and again
* split across the chunks -- before any framing. The group event that carries
* the proposal is sized by ChronicleEvent.MAX_PAGE_BYTES; half of it, less room
* for k event envelopes, is what is left for prose.
*
* Measure it against a real proposal rather than trusting this arithmetic, the
* way MAX_PAGE_BYTES itself says to.
*/
const val MAX_ORIGINAL_TEXT_BYTES = 24 * 1024
```
**Built differently, in three ways, and the measurement is why.** Encoding real
proposals gave a per-event overhead of 357–388 bytes across chapters from 1 KiB
to 24 KiB and from 1 to 63 chunks — the plan's ~370 estimate, arrived at for the
wrong reason: a proposal carries *unsigned* events, so there is no 128-character
signature in it, and the figure is instead an id, a pubkey, an empty `sig`, the
JSON structure and, on a chunk, the 64-character chapter id it points at.
24 KiB rather than 28: the envelopes are not free (an id, a pubkey and a
signature are ~370 bytes before an event says anything, times `k`), and a cap
that is comfortably under the wire limit fails in the form instead of on the
wire. It should be arrived at by measuring an encoded proposal for a 63-chunk
chapter, not by this paragraph.
What that measurement changed:
**And the number is provisional until Phase 8 lands.** Block splitting produces
more chunks than blank-line splitting, so the same prose carries more envelopes;
whichever of the two phases goes second re-runs the measurement.
**One. A flat `MAX_ORIGINAL_TEXT_BYTES` is wrong by half.** Measured, a
63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits about
30, because the envelopes are what varies. A single constant safe for the first
refuses a third of the second's text for nothing; one sized for the second
stalls sessions. The chunk count is known as the text is typed, so the limit may
as well be — `ChapterProposal.proposalBytes(text, chunks)` and
`maxOriginalTextBytes(chunkCount)` instead of a constant.
**Two. The limit belongs to the manager, not to the chapter.**
`FrostSigningManager.MAX_PROPOSAL_BYTES = 64 * 1024`, beside `MAX_BATCH_SIZE`,
enforced in `proposeSigningBatch` against the real encoding and *independently*
in `acceptProposal` — before parsing, since that is the cheaper refusal. The
count cap already had exactly this two-sided treatment for exactly this reason:
the proposal is where a remote party decides how much work everybody else does,
and until now its *size* was unbounded. Both propose forms funnel into the
`lead`/`dependents` one, so a single check covers them.
**Three. `ChapterProposal` estimates what the manager measures.** The exact
answer needs the chapter built and the chunks cut, which is one line from
opening a session — too late to tell somebody with a cursor in a text field.
So: a cheap estimate for the form at 448 bytes per event, rounded up from the
measured 388, and a test asserting the estimate never falls under the real
encoding. Over-estimating costs an author a slightly shorter chapter;
under-estimating costs the group a stalled session, so the rounding goes that
way on purpose.
**The number is still provisional until Phase 8 lands.** Block splitting
produces more chunks than blank-line splitting, so the same prose carries more
envelopes; `ChapterProposalSizeTest` pins where the fit runs out (24 KiB over
forty paragraphs), so that phase will move a pinned figure rather than quietly
narrow the limit.
Where it goes:
@@ -326,8 +340,11 @@ Where it goes:
`paragraphs.size > MAX_CHUNKS_PER_CHAPTER` guard, same early `onFailure`.
- `AddChapterScreen` — the counter line already reads
`"$wordCount words · $characterCount characters · $paragraphCount chunks of N"`;
it gains the byte figure against the cap and turns `error`-coloured on the
same rule the chunk count does.
it gains the proposal's size against the cap and turns `error`-coloured on the
same rule the chunk count does. The message compares like with like — what
proposing would send, against what a group message carries — rather than a
character count against a byte budget, which was the first draft of it and was
comparing UTF-16 units to bytes.
- The two existing hardcoded English literals on that screen (the no-shared-key
message and the too-many-chunks message) move into
`composeApp/src/commonMain/composeResources/values/strings.xml` while the file
@@ -335,13 +352,15 @@ Where it goes:
title-case strings with literals reported; a third one added by this work
would be the wrong direction.
**Test:** extend `ChapterBatchProposalJvmTest`, which already asserts the
paragraph cap's boundary (`MAX_BATCH_SIZE - 1` proposes, `MAX_BATCH_SIZE`
throws). Add the byte boundary the same way: one chapter at the cap proposes,
one a byte over does not. And one that measures the *encoded proposal* for a
cap-sized, 63-chunk chapter and asserts it is under `MAX_PAGE_BYTES` — that is
the assertion that makes the constant honest, and the only one that will catch
it drifting when a tag is added to either kind.
**Tests:** a `ChapterProposalSizeTest` in `commonTest` — pure, since the
estimate and the encoding are both pure — over the grid the overhead was
measured on, asserting the estimate never falls under the real encoding and
stays within a tenth of it. Plus the round trip the form's offer rests on: the
longest text the estimate allows at the worst chunk count really does encode
under the cap. Plus the boundary pinned as a number, so a tag added to either
kind moves a figure in a test rather than producing a quorum that never
arrives. And the byte-vs-character one, in Amharic, because an ASCII-only test
suite would never have caught the UTF-16 slip above.
---