fix: tell one proposal's transcript lines from another's

A room with two proposals open showed "Review" on both, and then dropped it from
both the moment either one was decided. The second proposal was still waiting on
the reader, still had a decision in it, and had nowhere left to be reached from.

Two proposals at once is not a corner case any more: a chapter and the
translation scaffolding beside it are proposed as separate sessions, on purpose,
and they run at the same time. Both write the same line types into the same
stretch of transcript.

`answeredRequests` matched a request against any later line of the fulfilling
type, and `settledRequests` against any later ending. That reads a room signing
one thing at a time exactly right -- the nonce after the request is the answer to
it, because there is nothing else it could be an answer to -- and a room signing
two things at once exactly wrong. Nothing else on the row could separate them:
same type, same room, same minute, and `ChatMessage` carried no session.

So the session goes on the row. `ChatMessage.frostSigningSessionId` is nullable,
added as schema v11 through `AutoMigration(10, 11)`, and stamped by
`FrostSigningManager.announce` -- the one place every FROST line is written, so
there is no line that can be forgotten. Both rules read it when both rows have
one and fall back to the clock when either does not.

The fallback is not a compromise, it is the right reading of the rows it applies
to. A line written before this column has no session and never will, and the
rooms that wrote those lines could not run two sessions at once, so the clock is
the whole truth there. A ceremony line falls back too and always will: a room
runs one ritual at a time, and a DKG step is either taken or still waited on.

**This reverses a call `FrostSigningRoute` argued for.** Its note said a chat row
carrying a session id was "a poor trade for a lookup the screen can do". That was
right when the lookup could only be wrong about which of one session it meant.
The batch work made two sessions ordinary, and the lookup and the rules both
became guesses at the same moment. A column on the table every message uses is
the cost; two proposals, one of them unreachable, was the alternative.

**Tests.** Three in TranscriptRequestStateTest for what the column buys: a nonce
answers its own session's request and not the other's, one session completing
settles nothing in the other, and a line naming no session is still read by the
clock. TranslationBatchProposalJvmTest proves the other half against a real
two-session proposal -- every FROST line the manager writes names its own
session, and neither session's lines are attributed to the other. The rule is
tested on rows and the stamping is tested on a database, because a rule that is
right about rows nothing writes correctly is worth nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:41:01 +02:00
parent 3fade6c849
commit 4de87edf12
7 changed files with 5614 additions and 9 deletions

View File

@@ -174,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 10,
version = 11,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -216,12 +216,19 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// before it read back null, meaning the untweaked threshold key -- which
// is what they were signing as, so one caught mid-flight finishes the way
// it began rather than switching keys between two of its own rounds.
AutoMigration(from = 8, to = 9)
AutoMigration(from = 8, to = 9),
// v10 moves FrostSigningSession's five per-event columns onto the new
// FrostSigningItem table, so one session can sign a batch. It copies
// before it drops, which no AutoMigration can express -- see
// MIGRATION_9_10, and the note there on why an in-flight session losing
// its nonce seed would be worse than losing the session.
//
// v11 adds the nullable ChatMessage.frostSigningSessionId, which says
// which signing session a transcript line is about. Lines written before
// it read back null and are read by the clock, the way they always were:
// a room that could only propose one thing at a time cannot have written
// two sessions' lines into the same stretch of transcript.
AutoMigration(from = 10, to = 11)
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -94,6 +94,23 @@ data class ChatMessage(
*/
val directMessageRecipientPublicKey: HexKey? = null,
/**
* The signing session a [FROST_TYPES] line belongs to, or null for anything else.
*
* The transcript used to work this out from the type and the clock: a request
* was over once a line of the right type appeared after it. That reads a room
* signing one thing at a time correctly and a room signing two things at once
* wrongly -- a chapter and the translation that depends on it are proposed as
* two sessions, and either one finishing dropped the summons on both. There is
* nothing in a row's type or time that can tell them apart, so the session has
* to be on the row.
*
* Null on every line written before this column existed, which the rules below
* fall back to their old behaviour for. Correct for those rooms too: nothing
* that predates the batch proposals ran two sessions at once.
*/
val frostSigningSessionId: String? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
@@ -374,13 +391,32 @@ data class ChatMessage(
val done = messages.any {
it.messageType == published &&
it.isUserMessage &&
it.createdAt >= request.createdAt
it.isAbout(request)
}
request.id.takeIf { done }
}
.toSet()
/**
* Whether this line is part of the same piece of business as [request].
*
* A signing line says which session it belongs to, and that answer is
* exact: two sessions running at once are two separate decisions, and
* one of them ending says nothing about the other.
*
* Everything else falls back to the clock, which is what the transcript
* always used. It is right for a ceremony -- a room runs one at a time --
* and it is what a line written before [frostSigningSessionId] existed
* has to be read by.
*/
private fun ChatMessage.isAbout(request: ChatMessage): Boolean =
if (frostSigningSessionId != null && request.frostSigningSessionId != null) {
frostSigningSessionId == request.frostSigningSessionId
} else {
createdAt >= request.createdAt
}
/**
* The signing requests that are no longer open, by row id.
*
@@ -394,7 +430,7 @@ data class ChatMessage(
.filter { request ->
request.messageType in FROST_REQUEST_TYPES &&
messages.any {
it.messageType in FROST_SETTLEMENTS && it.createdAt >= request.createdAt
it.messageType in FROST_SETTLEMENTS && it.isAbout(request)
}
}
.map { it.id }

View File

@@ -1628,6 +1628,11 @@ object FrostSigningManager {
* Written once by construction rather than de-duplication: every caller checks
* before it writes, because [ChatMessage] has no key to make a second insert a
* no-op.
*
* Every line names its session. A room signs repeatedly and can have two
* sessions open at once, so which one a line is about is the difference
* between a request that is still asking and one that has been answered --
* see [ChatMessage.frostSigningSessionId].
*/
private suspend fun announce(
database: MantraDatabase,
@@ -1645,7 +1650,8 @@ object FrostSigningManager {
marmotInnerEventId = null,
chatRoomId = session.chatRoomId,
content = content,
messageType = messageType
messageType = messageType,
frostSigningSessionId = session.id
)
)
}