fix(frost): one transcript line for a wave, not one per dialect

A signed event files its chat line unless the batch holds more than one event of
its kind, in which case the session's completion line speaks for them.

Needed by the previous commit. A wave of fifty dialects would otherwise post
fifty "Added X to dialects" lines into the group's conversation, one after
another, followed by a line saying the group signed fifty events -- a single act
burying the conversation it was announced in.

Every kind that could already appear many-per-batch returns null from
applyInnerEvent for precisely this reason: a chunk is part of a chapter and the
chapter's line speaks for it, and likewise a translation chapter, a contributor
list and both project memberships. DialectEvent is the first kind where a batch
holds many peers each of which wants a line. So checked against every batch the
app builds today, this changes nothing: the leading kind of each is one of its
kind and keeps its line.

It is not a rule about batch size, which is the mistake available here. A chapter
and its sixty-four chunks is one ChapterEvent, and gating on the batch being
larger than one event would have taken the chapter's line away.

Decided at the call site because that is the only place that can see the batch.
applyInnerEvent is untouched -- it still writes the row, which is the part that
matters -- and only the chatMessageDao upsert is skipped. ChronicleManager needed
nothing: it already drops the line a payload returns, and says why.

Three tests on SignedGroupKeyStateTest, which runs real two-device sessions: one
dialect still files its line, three file none and still land three rows, and a
dialect with the artifact that names it files both. The first two are the same
mechanism asserted at 1 and at 0, so neither can pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 22:59:14 +02:00
1 parent c03112fbbc
commit 226653d282
2 files changed
+160 -3

No files matched your search

@@ -975,7 +975,32 @@ object FrostSigningManager {
// re-authors rumors as their sender and would strip the group's
// signature off. Sending one to relays is a member's separate act,
// from `BroadcastGroupSignedEventScreen`, off the recorded row.
signedEvents.forEach { applySignedEvent(database, session, it, recorded) }
//
// A line per event, *unless the batch holds more than one of its kind*,
// in which case the completion line below speaks for them. Peers in a
// batch are the case that needs it: a wave of fifty dialects would
// otherwise post fifty "Added X to dialects" lines into the group's
// conversation, one after the other, followed by a line saying the group
// signed fifty events.
//
// Every kind that could already appear many-per-batch returns null from
// `applyInnerEvent` for precisely this reason -- a chunk is part of a
// chapter and the chapter's line speaks for it, and likewise a
// translation chapter, a contributor list and both project memberships --
// so this changes nothing for any batch built before the dialect wave.
// What it is not is a rule about batch *size*: a chapter and its
// sixty-four chunks is one ChapterEvent, and its line stays.
val kinds = signedEvents.groupingBy { it.kind }.eachCount()
signedEvents.forEach { signedEvent ->
applySignedEvent(
database = database,
session = session,
signedEvent = signedEvent,
recorded = recorded,
fileChatLine = kinds[signedEvent.kind] == 1,
)
}
announce(
database = database,
@@ -1040,12 +1065,18 @@ object FrostSigningManager {
* A failure here is not the session's: the signature is made and valid, and
* saying otherwise would tell the group to abandon a ceremony that
* succeeded. It is logged and the session still completes.
*
* [fileChatLine] is false for an event the batch holds several of a kind of;
* the row is written either way, and only the transcript line is skipped.
* See the rule where it is decided, at the call site -- which is the only
* place that can see the batch, and so the only place that can know.
*/
private suspend fun applySignedEvent(
database: MantraDatabase,
session: FrostSigningSession,
signedEvent: Event,
recorded: Boolean
recorded: Boolean,
fileChatLine: Boolean = true
) {
try {
ChatMessage.applyInnerEvent(
@@ -1058,7 +1089,7 @@ object FrostSigningManager {
isUserMessage = session.isCoordinator(),
createdAt = Clock.System.now(),
groupSignedEventId = signedEvent.id.takeIf { recorded }
)?.let { database.chatMessageDao().upsert(it) }
)?.takeIf { fileChatLine }?.let { database.chatMessageDao().upsert(it) }
} catch (e: Throwable) {
logger.e("Signed ${signedEvent.id} but could not apply it locally", e)
}