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