test(chapters): twelve proposals each keep their own summons

Phase 6 of docs/chapter-uploads.md, which asked for three things to be
checked rather than assumed. All three already hold, so this is their tests
and the record of it.

The proposal list already groups: a "Waiting for you" header over the
sessions isAwaitingApproval picks out, "Everything else" under them, over a
query already ordered newest first. The chapter summary is already
`name - N words - N chunks` rather than "Chapter", so twelve rows are twelve
distinguishable rows. And isAbout already compares frostSigningSessionId when
both rows carry one, falling back to the clock only when either does not.

The last of those is the one with a history -- two concurrent sessions broke
it once, which is why the column exists -- so it is now tested at twelve, with
the answers given in the opposite order to the asking, so that neither the
clock nor the list order could produce the right answer by accident. Plus one
proposal failing and settling only its own row, which is the transcript's
version of why an upload is N sessions and not one.

Noted for Phase 8 rather than fixed here: twenty open chapter proposals is
roughly 900 KB of unsignedEventJson parsed and summarised on every emission of
that query, and summarize then re-splits each chapter's text to count its
chunks. Phase 8 replaces that derived count with the session's own item count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 19:36:14 +02:00
parent 428026cdfd
commit f8a0ee1cdb
2 changed files with 67 additions and 13 deletions

View File

@@ -182,4 +182,42 @@ class TranscriptRequestStateTest {
assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript))
}
@Test
fun `twelve open proposals each keep their own summons`() {
// An upload of twelve files opens twelve sessions at once, interleaved in
// one stretch of transcript. The clock fallback would have the first
// answer drop every later request, which is the failure two concurrent
// sessions already had once -- and twelve is where a path that still
// falls back to the clock would show.
val requests = (1..12).map { n ->
line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n)
}
// Answered in the opposite order to the asking, so neither the clock nor
// the list order could produce the right answer by accident.
val answers = listOf(12, 7, 1).map { n ->
line(ChatMessage.TYPE_FROST_NONCE, at = 200L + n, session = "session-" + n)
}
val answered = ChatMessage.answeredRequests(requests + answers)
assertEquals(
setOf(requests[11].id, requests[6].id, requests[0].id),
answered,
)
}
@Test
fun `one proposal failing settles only itself`() {
val requests = (1..12).map { n ->
line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n)
}
val failure = line(ChatMessage.TYPE_FROST_FAILED, at = 300, session = "session-5")
// The other eleven chapters are still in front of the group. A batch is
// all-or-nothing within a session and nothing across them, which is the
// whole reason an upload is N sessions rather than one.
assertEquals(setOf(requests[4].id), ChatMessage.settledRequests(requests + listOf(failure)))
}
}

View File

@@ -615,23 +615,39 @@ The transcript and the proposal list were built for two concurrent sessions —
scaffolding were open together. Twelve is the same problem at a scale that will
find whatever that fix missed.
What to check rather than assume:
What to check rather than assume — and **all three already held**, so this
phase is its tests and this paragraph:
- `ProposalListScreen` with twenty open sessions: the ones waiting on the reader
gather at the top, and `ProposedEvent.summarize` gives a chapter a name rather
than "Event of kind 30302". Chapters are summarised today; confirm the
summary is the chapter's *name*, since twelve rows reading "Chapter" are
twelve rows a member cannot choose between.
- Twelve "Review" lines in the transcript, each resolving its own session.
`answeredRequests`/`settledRequests` read `frostSigningSessionId` when both
rows have one; twelve is where a fallback-to-clock path would show.
- Twelve approval gates. No batching of the gate — that is the one thing the
batch-signing note refuses, and for the same reason: "a member who cannot
scroll the whole list should not be able to approve it". What can be done is
make the *list* cheap to work through, which is the point above.
- **The list already groups.** `ProposalListScreen` renders a "Waiting for you"
header over the sessions `FrostSigningManager.isAwaitingApproval` picks out and
an "Everything else" header under them, over a query that is already
`ORDER BY createdAt DESC`. Twelve proposals arrive sorted and the three that
need the reader are at the top.
- **The summary already names the chapter.** `ProposedEvent.summarize`'s chapter
branch is `name · N words · N chunks`, not "Chapter" — so twelve rows are
twelve distinguishable rows.
- **`isAbout` already matches on the session.** `answeredRequests` and
`settledRequests` compare `frostSigningSessionId` when both rows carry one and
fall back to the clock only when either does not. Now tested at twelve, with
the answers given in the opposite order to the asking so that neither the
clock nor the list order could produce the right answer by accident, plus one
failure settling only its own row.
- **Twelve approval gates, unbatched.** Unchanged by design: the gate shows every
event rather than a count, because "a member who cannot scroll the whole list
should not be able to approve it".
No new `ChatMessage` type, no new set to add to.
### One thing N does make worse, and it is Phase 8's to fix
`observeSessionsForChatRoom` carries every session's items, and a chapter item's
`unsignedEventJson` holds the whole markdown. Twenty open chapter proposals is
roughly 900 KB of JSON parsed and summarised **on every emission** — and
`summarize` then re-splits each chapter's text to count its chunks, which is the
derived count Phase 8 replaces with the session's own item count. Fixing it
there removes the re-split; the parse is inherent to the query and is the price
of the list showing what each proposal signs without one query per row.
---
## Phase 7 — the splitter, which is a decision about translation