feat: keep what the group signed, and the path it signed as

A quorum signing something is the most expensive thing this app does and, until
now, the least recorded. `FrostSigningManager.complete` verified the signature,
handed the event to `ChatMessage.applyInnerEvent`, and let it go. What survived
was whatever the row it became happened to keep -- an artifact keeps its
`signature` and `publicKey`, a translation contributor list keeps nothing at all
because that arm is still a TODO, and a kind this build has no arm for keeps
nothing anywhere. The signature is the group's statement; the rows are one
reading of it. `GroupSignedEvent` is where the statement itself now lives, at
schema v13 behind an `AutoMigration(12, 13)`.

**The columns are `NostrEvent`'s, not a summary of one.** `id`, `publicKey`,
`kind`, `tags`, `content`, `signature` and the event's own `created_at` as
`createdAt`, so what is stored is an event rather than a description of one.
That is what makes `verifies()` answerable from the row alone: it delegates to
`GroupKeyStateEvent.isSignedByRoom`, which asks whether the author is the room,
whether the id is the hash of the fields sitting next to it, and whether the
signature checks out. No ceremony, no key state and no path have to be on hand
first -- which is exactly the position a member added after the ceremony is in.

**The derivation path is the point of the exercise.** `publicKey` is the group's
threshold key walked to `derivationPath`, and for a room that walk is also the
room id -- see docs/shared-key-derivation.md, where those are one value. Without
the path there is no way back from a signature to the ceremony behind it: a
threshold key alone does not say which of a group's rooms signed, and a room id
alone cannot be walked backwards. `GroupKeyState` records the path for the room;
this records it for the event, so an event stays checkable after the room's
state is gone or was never known. Null means the untweaked threshold key, the
same meaning it carries on `FrostSigningSession.derivationPath`, which is where
the signing path is copied from -- resolved from the room by `signingPath`,
never from a proposer.

**Two writers, and both file only what they have already checked.**
`FrostSigningManager.recordSignedEvents` files a whole batch in one write, after
every item's signature has verified and before any of them is applied -- a
session's events are one decision by one quorum, so half a batch on file is a
state no reader should have to reason about. `ArchiveManager.applyPage` files
each payload it accepts, after the allowlist and
`GroupKeyStateEvent.isSignedByRoom`, reading the room's path once per page from
`GroupKeyState` rather than once per payload. Neither failure is the caller's:
recording throws are logged and swallowed, because a ceremony that succeeded
must not be reported as failed over a row this device could not write down.

**The archive half is what makes a recipient more than a dead end.** A member
handed their history used to end up holding the rows and none of the events --
able to read the group's work, unable to prove any of it, and unable to build a
page for the next member to arrive. Now the events land too.

**`record` merges rather than overwrites, and that direction is deliberate.**
The same event reaches a device twice by design: once when the session that made
it completes, once from any archive page carrying it. The second arrival is the
poorer one -- an archive knows no session, and on a member who joined after the
ceremony no derivation path either -- so the incoming row fills gaps and never
empties them. The event's own fields are not merged because they cannot
disagree: the id is the hash of them, so two rows under one id either hold the
same event or one of them is not the event it claims to be.

**Every `Mantra*` row points back at it.** `groupSignedEventId` on all twelve
entities that carry `marmotGroupEventId`, stamped by `ChatMessage.applyInnerEvent`
through a new defaulted parameter. On a group-signed row it is the only
provenance there is: both Marmot ids are null, because there is no group event
and no inner event behind one -- a signed event authored by the threshold key
cannot travel as an inner event at all, since the outbound pipeline re-authors
rumors as their sender and would strip the signature off. The column is only set
when the record actually landed, so a row never points at an event that is not
there.

**`ArchiveManager`'s own doc said something that is no longer true.** It opened
with "signed events are not stored as events", stated as present-tense fact and
load-bearing for the paragraph under it. Corrected there and noted at the head
of the same section in docs/member-archive.md, which is a phase history and so
gets a note rather than a rewrite. Assembly still rebuilds payloads from rows via
`toXEvent()` and the round-trip gate still holds it up: a room whose work
predates v13 has no events on file, and rebuilding is the only way to reach it.
Reading assembled events from the table is worth doing once that fallback can be
dropped.

**Two things this deliberately does not touch.** `ChatMessage` gets no such
column -- it is not a `Mantra*` row and already carries `frostSigningSessionId`
for the lines that need to name a session. `MantraTranslationChunkProposal` has
a `marmotGroupEventId` but is not a `@Database` entity and nothing in
`composeApp/src` references it, so it was left as the dead code it is rather
than grown a column.

Rows are not backfilled by the migration. The events they came from are gone,
and minting an id for one would point a row at a signature nobody can produce;
null reads as "this device does not hold the event behind this row", which is
true of every row written before today.

490 jvm tests and 297 android unit tests pass. `GroupSignedEventDaoJvmTest` is
eight cases against a real 2-of-3 quorum rather than a stub signature, because a
fake one would satisfy every column assertion and prove nothing -- it covers the
round trip, the path walking back to the row's own author, the merge in both
directions, batch ordering, and a row edited after the fact no longer verifying.
`SignedGroupKeyStateTest` adds the end-to-end claim over two devices: a batch of
three signed in one session lands as three events on both, each at `m/9420/0/0`
that neither device was told and both derived from the room they stand in.
`ArchiveApplyJvmTest` asserts the receiver ends up holding the events and not
only the rows, and that the four forgeries in its adversarial page become no
signed-event rows either -- a forgery filed there is one the recipient goes on
to hand to everybody else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 15:43:18 +02:00
parent 47aa79ebc7
commit 8f9e4de82e
23 changed files with 6543 additions and 13 deletions

View File

@@ -21,6 +21,7 @@ import press.mantra.compose.database.dao.GiftWrapMessageDao
import press.mantra.compose.database.dao.GiftWrapPayloadDao
import press.mantra.compose.database.dao.GiftWrapSealDao
import press.mantra.compose.database.dao.GroupKeyStateDao
import press.mantra.compose.database.dao.GroupSignedEventDao
import press.mantra.compose.database.dao.InReplyToRelationDao
import press.mantra.compose.database.dao.MantraArtifactDao
import press.mantra.compose.database.dao.MantraArtifactVersionDao
@@ -73,6 +74,7 @@ import press.mantra.compose.database.model.GiftWrapMessage
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GiftWrapSeal
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
@@ -137,6 +139,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
GroupSignedEvent::class,
InReplyToRelation::class,
MantraArtifact::class,
MantraArtifactVersion::class,
@@ -174,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 12,
version = 13,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -235,7 +238,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// null, meaning "never asked" -- true of all of them, and harmless: the
// request only goes out for a room that holds no signed work, and a room
// that holds some will not ask.
AutoMigration(from = 11, to = 12)
AutoMigration(from = 11, to = 12),
// v13 adds the GroupSignedEvent table and the nullable
// `groupSignedEventId` on every row a signed event turns into. A new
// table and nullable additions are both shapes Room migrates itself.
// Rows written before it read back null, meaning "this device does not
// hold the event behind this row" -- true of all of them, since nothing
// kept it. They are not backfilled: the events are gone, and inventing
// an id for one would point a row at a signature nobody can produce.
AutoMigration(from = 12, to = 13)
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -259,6 +270,8 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun groupKeyStateDao(): GroupKeyStateDao
abstract fun groupSignedEventDao(): GroupSignedEventDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao

View File

@@ -0,0 +1,85 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
import press.mantra.compose.database.model.GroupSignedEvent
@Dao
abstract class GroupSignedEventDao {
@Query("SELECT * FROM GroupSignedEvent WHERE id = :id")
abstract suspend fun getById(id: HexKey): GroupSignedEvent?
/**
* A room's signed work, oldest first.
*
* Oldest first because that is the order it can be applied in: a chunk names
* a chapter, and the group signed the chapter first. It is not the order to
* *show* anything in.
*/
@Query("SELECT * FROM GroupSignedEvent WHERE chatRoomId = :chatRoomId ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByChatRoomId(chatRoomId: String): List<GroupSignedEvent>
@Query("SELECT * FROM GroupSignedEvent WHERE chatRoomId = :chatRoomId AND kind = :kind ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByChatRoomIdAndKind(chatRoomId: String, kind: Kind): List<GroupSignedEvent>
/** Everything one session signed, in the order the batch was proposed in. */
@Query("SELECT * FROM GroupSignedEvent WHERE frostSigningSessionId = :sessionId ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByFrostSigningSessionId(sessionId: String): List<GroupSignedEvent>
/** Whether this device holds any of a room's signed work. */
@Query("SELECT COUNT(*) FROM GroupSignedEvent WHERE chatRoomId = :chatRoomId")
abstract suspend fun countByChatRoomId(chatRoomId: String): Int
@Upsert
abstract suspend fun upsert(groupSignedEvent: GroupSignedEvent)
/**
* Files a signed event, keeping what is already known about it.
*
* The same event reaches a device more than once by design: it is applied
* when the session that made it completes, and again from any archive page
* carrying it. A plain upsert would let the second arrival overwrite the
* first, and the second arrival is the poorer one -- an archive knows no
* session and, on a member who joined after the ceremony, no derivation
* path. So the incoming row fills gaps and never empties them.
*
* The event's own fields are not merged, because they cannot disagree:
* [GroupSignedEvent.id] is the hash of them, so two rows under one id either hold the same event
* or one of them is not the event it claims to be.
*
* Returns the row now on file.
*/
@Transaction
open suspend fun record(groupSignedEvent: GroupSignedEvent): GroupSignedEvent {
val known = getById(groupSignedEvent.id)
val merged = groupSignedEvent.copy(
derivationPath = groupSignedEvent.derivationPath ?: known?.derivationPath,
frostSigningSessionId = groupSignedEvent.frostSigningSessionId
?: known?.frostSigningSessionId,
// The event cannot change, so only a re-record moves this; savedAt
// stays at the moment this device first held the group's work.
updatedAt = if (known == null) groupSignedEvent.updatedAt else Clock.System.now(),
savedAt = known?.savedAt ?: groupSignedEvent.savedAt,
)
upsert(merged)
return merged
}
/**
* Files a whole batch as one write.
*
* A session's events are signed together and applied together, so half a
* batch on file is a state no reader should have to think about.
*/
@Transaction
open suspend fun recordAll(groupSignedEvents: List<GroupSignedEvent>): List<GroupSignedEvent> =
groupSignedEvents.map { record(it) }
}

View File

@@ -814,6 +814,14 @@ data class ChatMessage(
* the envelope a member actually sent. Entity rows take their author
* from [event], so the chat line says who added it and the row says who
* wrote it.
*
* [groupSignedEventId] is the third way in, and the one with no
* envelope at all: a `GroupSignedEvent` the group put its own signature
* to, applied by `FrostSigningManager` when a session completes or by
* `ArchiveManager` when a page arrives. Both Marmot ids are null for
* those -- there is no group event and no inner event behind them --
* which is why they need an id of their own, and why every row this
* writes carries it.
*/
internal suspend fun applyInnerEvent(
database: MantraDatabase,
@@ -824,6 +832,7 @@ data class ChatMessage(
senderPublicKey: HexKey,
isUserMessage: Boolean,
createdAt: Instant,
groupSignedEventId: HexKey? = null,
): ChatMessage? {
return when (event.kind) {
ChatEvent.KIND -> {
@@ -856,6 +865,7 @@ data class ChatMessage(
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -894,6 +904,7 @@ data class ChatMessage(
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -926,6 +937,7 @@ data class ChatMessage(
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -958,6 +970,7 @@ data class ChatMessage(
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -980,6 +993,7 @@ data class ChatMessage(
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -1012,6 +1026,7 @@ data class ChatMessage(
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
@@ -1047,6 +1062,7 @@ data class ChatMessage(
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
// TODO: translation chapter might be too noisy for chat updates
@@ -1098,6 +1114,7 @@ data class ChatMessage(
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
}
@@ -1123,6 +1140,7 @@ data class ChatMessage(
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
)
ChatMessage(

View File

@@ -0,0 +1,203 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* An event the group's own key put a signature to, kept as the group made it.
*
* A quorum signing something is the most expensive thing this app does and,
* until this table, the least recorded: `FrostSigningManager.complete` verified
* the signature, handed the event to `ChatMessage.applyInnerEvent`, and let it
* go. What survived was whatever the row it became happened to keep -- an
* artifact keeps its `signature`, a contributor list keeps nothing at all, and a
* kind with no arm in `applyInnerEvent` keeps nothing anywhere. The signature is
* the group's statement; the rows are one reading of it. This is where the
* statement itself lives.
*
* ### Why the whole event, and not a pointer to one
*
* There is nothing to point at. A group-signed event never travels on the wire
* as itself -- the outbound pipeline re-authors rumors as their sender and would
* strip the signature off -- so it is not a `NostrEvent`, and it is not a
* `MarmotInnerEvent` either, since no member sent it. Every device derives it
* from a signing session it took part in, or is handed it in an archive. The
* columns are `NostrEvent`'s so that what is stored is an event rather than a
* summary of one, which is what makes [verifies] answerable from the row alone.
*
* ### The derivation path
*
* [publicKey] is the group's threshold key walked to [derivationPath], and for a
* room that walk is also [chatRoomId] -- see `docs/shared-key-derivation.md`,
* where the room's id and the key it signs as are one value. Storing the path is
* what lets a reader get from a signature back to the ceremony behind it: the
* threshold key alone does not say which of a group's rooms signed, and a room
* id alone cannot be walked backwards. `GroupKeyState` records the same path for
* the room; this records it for the event, so an event stays checkable after the
* room's state is gone or was never known.
*
* Null means the untweaked threshold key itself, which is what a room not
* derived from the key signs as -- the same meaning it carries on
* [FrostSigningSession.derivationPath], which is where it is copied from.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("chatRoomId"),
Index("kind"),
Index("frostSigningSessionId"),
],
)
data class GroupSignedEvent(
/** The event id, which is the 32 bytes the group actually signed. */
@PrimaryKey
val id: HexKey,
/** The room whose key signed it. Its id *is* [publicKey] for a derived room. */
val chatRoomId: String,
/** The author: the group's key at [derivationPath], x-only 32-byte hex. */
val publicKey: HexKey,
val kind: Kind,
val tags: Array<Array<String>>,
val content: String,
/** The finished 64-byte BIP-340 signature over [id], hex. */
val signature: HexKey,
/**
* The path [publicKey] was derived at off the ceremony's threshold key,
* `m/9420/0/0` style, or null for the untweaked key itself.
*/
val derivationPath: String? = null,
/**
* The session that produced the signature, when this device ran it.
*
* Not a foreign key, and null on a device that was handed the event rather
* than signing it -- an archive recipient holds the group's work without
* ever having held a session for it, which is the whole point of
* `docs/member-archive.md`. A session is also allowed to be swept away
* without taking the group's signed work with it.
*/
val frostSigningSessionId: String? = null,
/** The event's own `created_at`, which is part of what was signed. */
override val createdAt: Instant,
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
): TimestampedEntity, LocalStoreEntity {
/** The signed event, rebuilt from this row exactly as the group signed it. */
fun toEvent(): Event = Event(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
kind = kind,
tags = tags,
content = content,
sig = signature,
)
/** [derivationPath] as indices, and an empty list when there is none. */
fun pathIndices(): List<Long> =
derivationPath?.let { SharedKeyDerivation.parsePathString(it) } ?: emptyList()
/**
* Whether the room this row names actually signed what the row holds.
*
* Everything needed is on the row, which is the property worth having: the
* author has to be the room, [id] has to be the hash of the fields sitting
* next to it, and [signature] has to verify. A row that fails is a row whose
* columns have drifted from the event they came from -- there is no
* ceremony, key state or derivation path to consult first.
*/
fun verifies(): Boolean = GroupKeyStateEvent.isSignedByRoom(toEvent(), chatRoomId)
companion object {
/**
* The row for a signed [event], as the group made it.
*
* Not verified here. Both callers have already checked the signature --
* `FrostSigningManager` before it applies a batch, `ArchiveManager`
* before it applies a payload -- and a check that runs twice tends to
* become a check nobody performs. [verifies] is for readers.
*/
fun fromEvent(
event: Event,
chatRoomId: String,
derivationPath: String? = null,
frostSigningSessionId: String? = null,
): GroupSignedEvent = GroupSignedEvent(
id = event.id,
chatRoomId = chatRoomId,
publicKey = event.pubKey,
kind = event.kind,
tags = event.tags,
content = event.content,
signature = event.sig,
derivationPath = derivationPath,
frostSigningSessionId = frostSigningSessionId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as GroupSignedEvent
if (id != other.id) return false
if (chatRoomId != other.chatRoomId) return false
if (publicKey != other.publicKey) return false
if (kind != other.kind) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (signature != other.signature) return false
if (derivationPath != other.derivationPath) return false
if (frostSigningSessionId != other.frostSigningSessionId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + chatRoomId.hashCode()
result = 31 * result + publicKey.hashCode()
result = 31 * result + kind
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + signature.hashCode()
result = 31 * result + (derivationPath?.hashCode() ?: 0)
result = 31 * result + (frostSigningSessionId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
return result
}
}

View File

@@ -59,6 +59,9 @@ data class MantraArtifact(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -55,6 +55,9 @@ data class MantraArtifactVersion(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -59,6 +59,9 @@ data class MantraChapter(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -56,6 +56,9 @@ data class MantraChunk(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -45,6 +45,9 @@ data class MantraDialect(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -54,6 +54,9 @@ data class MantraTranslation(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -64,6 +64,9 @@ data class MantraTranslationArtifactVersion(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -50,6 +50,9 @@ data class MantraTranslationArtifactVersionContributor(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -60,6 +60,9 @@ data class MantraTranslationChapter(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -52,6 +52,9 @@ data class MantraTranslationChapterContributor(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -61,6 +61,9 @@ data class MantraTranslationChunk(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -51,6 +51,9 @@ data class MantraTranslationContributor(
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/**
* Related nostrEventId if this is not a rumor...
*/

View File

@@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.extensions.toHex
import kotlin.time.Clock
@@ -28,13 +29,20 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
*
* ### The rows are the archive
*
* Signed events are not stored as events. `FrostSigningManager.complete` applies
* one and what survives is a `Mantra*` row, so every payload here is rebuilt with
* `toXEvent()` and stands or falls on that rebuild being byte-identical to what
* was signed. `ArchiveRoundTripTest` is what says it is, per kind, against a real
* quorum -- and it found two faults in the artifact's rebuild the first time it
* ran, both of which would have shipped payloads that every receiver drops as
* forgeries without a word.
* Every payload here is rebuilt from a `Mantra*` row with `toXEvent()`, and
* stands or falls on that rebuild being byte-identical to what was signed.
* `ArchiveRoundTripTest` is what says it is, per kind, against a real quorum --
* and it found two faults in the artifact's rebuild the first time it ran, both
* of which would have shipped payloads that every receiver drops as forgeries
* without a word.
*
* That was once the only way: a signed event was applied and what survived was
* the row. `GroupSignedEvent` now keeps the event too, and [applyPage] files one
* for every payload it accepts -- which is what lets a member who was handed
* their history hand it on. Assembly still walks the rows, because a room whose
* work predates that table has no events on file and rebuilding is the only way
* to reach it. Reading assembled events from the table instead is worth doing
* once the fallback can be dropped.
*
* ### Nothing unverifiable leaves
*
@@ -491,6 +499,14 @@ object ArchiveManager {
return Outcome()
}
// Read once for the page rather than per payload: every event in an
// archive was signed by the room it is arriving in, so they all share
// its path. Null when the recipient has no key state yet -- which is
// the normal case for the member an archive exists for, and why
// `GroupSignedEvent.derivationPath` is nullable and `record` fills it
// in later rather than overwriting it with null.
val derivationPath = GroupKeyStateManager.keyStateFor(database, chatRoomId)?.derivationPath
var outcome = Outcome()
ArchiveEvent.inApplyOrder(payloads).forEach { payload ->
@@ -518,6 +534,21 @@ object ArchiveManager {
}
outcome += try {
// Kept as the group signed it, before it is turned into rows.
// The check above is what earns it that: a payload reaching here
// is one the room signed, which is the same standard
// `FrostSigningManager` records its own batches on. Without this
// a member who was handed their history would hold the rows and
// none of the events, and could never build an archive of their
// own to hand on.
database.groupSignedEventDao().record(
GroupSignedEvent.fromEvent(
event = payload,
chatRoomId = chatRoomId,
derivationPath = derivationPath,
)
)
// The chat line this returns is deliberately dropped rather than
// filed. ChatMessage has an autoGenerate primary key, so there is
// no id to dedupe on and every applied payload would mint a new
@@ -534,6 +565,7 @@ object ArchiveManager {
senderPublicKey = payload.pubKey,
isUserMessage = false,
createdAt = stored.createdAt,
groupSignedEventId = payload.id,
)
Outcome(applied = 1)
} catch (error: Throwable) {

View File

@@ -27,6 +27,7 @@ import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
@@ -873,6 +874,14 @@ object FrostSigningManager {
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
// The group's own statement, filed before anything is derived from it.
// What the batch is applied *into* is a reading of these events -- an
// artifact keeps some of its fields, a contributor list keeps none --
// and the reading is the part that can be wrong or, for a kind this
// build has no arm for, missing entirely. So the events are kept as
// signed, and the rows made from them point back at these.
val recorded = recordSignedEvents(database, session, signedEvents)
// A signature exists to be used. Every device has the event and the
// signature by now, so each applies the result itself rather than
// waiting to be sent something it can already build -- the same
@@ -880,7 +889,7 @@ object FrostSigningManager {
// wire: a signed event authored by the threshold key cannot travel as
// an inner event anyway, because the outbound pipeline re-authors
// rumors as their sender and would strip the group's signature off.
signedEvents.forEach { applySignedEvent(database, session, it) }
signedEvents.forEach { applySignedEvent(database, session, it, recorded) }
announce(
database = database,
@@ -894,13 +903,53 @@ object FrostSigningManager {
logger.i("Signing session ${session.id} complete")
}
/**
* Files the batch as [GroupSignedEvent] rows, and says whether it landed.
*
* One write for the batch, because that is how it was signed: a session's
* events are one decision by one quorum, and half of them on file is a state
* no reader should have to reason about. The path comes from the session
* rather than from the events, since it is the session that resolved it from
* the room -- see [signingPath], and note that a null there means the
* untweaked threshold key rather than an unknown path.
*
* A failure is logged and swallowed, like [applySignedEvent]'s: the
* signature is made and valid either way, and a ceremony that succeeded must
* not be reported as failed because this device could not write it down.
* What the caller loses is the id to point the derived rows at, which is why
* this returns a boolean rather than nothing.
*/
private suspend fun recordSignedEvents(
database: MantraDatabase,
session: FrostSigningSession,
signedEvents: List<Event>
): Boolean = try {
database.groupSignedEventDao().recordAll(
signedEvents.map { signedEvent ->
GroupSignedEvent.fromEvent(
event = signedEvent,
chatRoomId = session.chatRoomId,
derivationPath = session.derivationPath,
frostSigningSessionId = session.id,
)
}
)
true
} catch (e: Throwable) {
logger.e("Signed session ${session.id} but could not record its events", e)
false
}
/**
* Turns the signed event into whatever it is: a dialect, an artifact, a
* chapter.
*
* Reuses the inbound path's dispatch rather than repeating it, with no group
* event and no inner event behind the row -- there is neither, and both
* columns are nullable for exactly this kind of locally-derived record.
* columns are nullable for exactly this kind of locally-derived record. What
* the row does get is the [GroupSignedEvent] it was made from, when
* [recorded] says one is on file; a row pointing at an event that is not
* there would be worse than one pointing at nothing.
*
* 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
@@ -909,7 +958,8 @@ object FrostSigningManager {
private suspend fun applySignedEvent(
database: MantraDatabase,
session: FrostSigningSession,
signedEvent: Event
signedEvent: Event,
recorded: Boolean
) {
try {
ChatMessage.applyInnerEvent(
@@ -920,7 +970,8 @@ object FrostSigningManager {
marmotInnerEventId = null,
senderPublicKey = session.coordinatorPublicKey,
isUserMessage = session.isCoordinator(),
createdAt = Clock.System.now()
createdAt = Clock.System.now(),
groupSignedEventId = signedEvent.id.takeIf { recorded }
)?.let { database.chatMessageDao().upsert(it) }
} catch (e: Throwable) {
logger.e("Signed ${signedEvent.id} but could not apply it locally", e)

View File

@@ -0,0 +1,341 @@
package press.mantra.compose.database.dao
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
/**
* The table that keeps what a quorum signed, checked against a real quorum.
*
* A fake signature would pass every column assertion here and prove nothing, so
* the events are signed by an actual t-of-n key derived at the room's own path
* -- the same construction `ArchiveApplyJvmTest` uses. That is what makes
* [GroupSignedEvent.verifies] worth asserting: it is answered from the row
* alone, and it is the only assertion that would notice a column quietly
* failing to round-trip.
*/
class GroupSignedEventDaoJvmTest {
private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
@AfterTest
fun closeDb() = db.close()
private val participants = 3
private val threshold = 2
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The room's id and the key it signs as are one value. */
private val chatRoomId = room.hex
private val derivationPath = SharedKeyDerivation.formatPath()
private val member = "8".repeat(64)
private fun groupSignature(eventId: String): String {
val cache = room.cache
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = cache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = cache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** A template as a finished signing session leaves it, authored by the room. */
private fun signed(template: EventTemplate<*>): Event {
val id = EventHasher.hashId(
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
return Event(
id = id,
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = groupSignature(id)
)
}
private val dialect = signed(
DialectEvent.build(name = "isiZulu", country = "ZA", language = "zu", createdAt = 1_700_000_000L)
)
/**
* The room, and the two rows it hangs off. `ChatRoom.userPublicKey` is a
* foreign key onto `Profile`, which is one onto `NostrEvent`, so the room
* cannot exist on its own -- and neither can a `GroupSignedEvent`, which is
* the constraint this seeding is really here to satisfy.
*/
private suspend fun seedRoom() {
val nostrEventId = "c".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = member,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(
Profile(publicKey = member, userName = "member", nostrEventId = nostrEventId)
)
db.chatRoomDao().upsert(
ChatRoom(
id = chatRoomId,
userPublicKey = member,
subject = "#admins",
description = SharedKeyDerivation.describe("Admins"),
mlsGroupState = null,
)
)
}
private fun rowFor(
event: Event = dialect,
path: String? = derivationPath,
sessionId: String? = "session-1",
) = GroupSignedEvent.fromEvent(
event = event,
chatRoomId = chatRoomId,
derivationPath = path,
frostSigningSessionId = sessionId,
)
@Test
fun `a signed event survives the round trip and still verifies`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor())
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored, "the event just recorded could not be read back")
assertEquals(dialect.pubKey, stored.publicKey)
assertEquals(dialect.kind, stored.kind)
assertEquals(dialect.content, stored.content)
assertEquals(dialect.sig, stored.signature)
assertEquals(dialect.createdAt, stored.createdAt.epochSeconds)
assertContentEquals(dialect.tags.map { it.toList() }, stored.tags.map { it.toList() })
// The claim the columns exist to support: the group's signature is still
// checkable from the row, with nothing else on hand.
assertTrue(stored.verifies(), "the stored row no longer verifies as the room's")
assertEquals(dialect.id, stored.toEvent().id)
}
@Test
fun `the path the room was derived at is on the row`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor())
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored)
assertEquals(derivationPath, stored.derivationPath)
assertEquals(SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, stored.pathIndices())
// The path is the whole point: walking it from the ceremony's threshold
// key has to arrive back at the author of the event that was stored.
assertEquals(
stored.publicKey,
SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = stored.pathIndices()
).hex
)
}
@Test
fun `an untweaked room records no path rather than a wrong one`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor(path = null))
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored)
assertNull(stored.derivationPath)
// Empty, not null: no walk at all is what a room signing as the
// threshold key itself did, and it is a path `derive` accepts.
assertTrue(stored.pathIndices().isEmpty())
}
@Test
fun `an archive arrival does not empty what the session knew`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor())
// The same event again, as a page hands it to a member who holds no key
// state and never ran the session.
db.groupSignedEventDao().record(rowFor(path = null, sessionId = null))
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored)
assertEquals(derivationPath, stored.derivationPath)
assertEquals("session-1", stored.frostSigningSessionId)
assertEquals(1, db.groupSignedEventDao().countByChatRoomId(chatRoomId))
}
@Test
fun `a later arrival fills in what the first one could not say`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor(path = null, sessionId = null))
db.groupSignedEventDao().record(rowFor())
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored)
assertEquals(derivationPath, stored.derivationPath)
assertEquals("session-1", stored.frostSigningSessionId)
}
@Test
fun `a batch is filed together and reads back oldest first`() = runBlocking {
seedRoom()
val artifact = signed(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialect.id,
versionLabel = "1.0",
createdAt = 1_700_000_010L
)
)
db.groupSignedEventDao().recordAll(
listOf(rowFor(event = artifact), rowFor(event = dialect))
)
val dao = db.groupSignedEventDao()
assertEquals(
listOf(dialect.id, artifact.id),
dao.getByChatRoomId(chatRoomId).map { it.id },
"a room's signed work has to come back in the order it can be applied in"
)
assertEquals(
listOf(dialect.id),
dao.getByChatRoomIdAndKind(chatRoomId, DialectEvent.KIND).map { it.id }
)
assertEquals(2, dao.getByFrostSigningSessionId("session-1").size)
}
@Test
fun `a row whose columns have drifted stops verifying`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor().copy(content = "isiXhosa"))
val stored = db.groupSignedEventDao().getById(dialect.id)
assertNotNull(stored)
// The id is the hash of the fields, so an edited row is an event the
// group never signed. Nothing else has to be consulted to know that.
assertFalse(stored.verifies(), "an event with edited content must not verify")
}
@Test
fun `the row a signed event becomes points back at it`() = runBlocking {
seedRoom()
db.groupSignedEventDao().record(rowFor())
ChatMessage.applyInnerEvent(
database = db,
groupId = chatRoomId,
event = dialect,
marmotGroupEventId = null,
marmotInnerEventId = null,
senderPublicKey = chatRoomId,
isUserMessage = false,
createdAt = Instant.fromEpochSeconds(dialect.createdAt),
groupSignedEventId = dialect.id,
)
val row = db.mantraDialectDao().getDialectById(dialect.id)
assertNotNull(row, "the dialect the group signed was not applied")
// Both Marmot ids are null on a group-signed event -- no group event and
// no inner event carried it -- so this column is the only provenance the
// row has.
assertNull(row.marmotGroupEventId)
assertEquals(dialect.id, row.groupSignedEventId)
}
}

View File

@@ -459,6 +459,48 @@ class ArchiveApplyJvmTest {
}
}
/**
* The member who was handed history can hand it on.
*
* Rows are a reading of what the group signed; the events are the thing
* itself, and until they are kept the recipient of an archive is a dead end
* -- holding the work, unable to prove any of it, and unable to build a page
* for the next member to arrive. The path is the room's, read from the key
* state when the recipient has one.
*/
@Test
fun `the archive leaves the receiver holding the events, not only the rows`() = runBlocking {
seedSenderWork()
seedRoom(receiver, newMember)
senderArchive().forEach { deliver(it) }
val signed = receiver.groupSignedEventDao().getByChatRoomId(chatRoomId)
assertEquals(
senderArchive()
.flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) }
.map { it.id }
.toSet(),
signed.map { it.id }.toSet(),
"every payload that applied should be on file as the group signed it"
)
assertTrue(signed.isNotEmpty())
signed.forEach { event ->
assertEquals(chatRoomId, event.publicKey, "${event.id} is not authored by the room")
assertTrue(event.verifies(), "${event.id} does not read back as the room's")
// Nothing on this device ran a session for any of it.
assertNull(event.frostSigningSessionId)
}
// The rows the payloads became point back at them, which is the only
// provenance a group-signed row has: no group event, no inner event.
val dialects = receiver.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
assertTrue(dialects.isNotEmpty())
dialects.forEach { assertEquals(it.id, it.groupSignedEventId) }
}
@Test
fun `an archive files no chat lines`() = runBlocking {
seedSenderWork()
@@ -581,6 +623,14 @@ class ArchiveApplyJvmTest {
val dialects = receiver.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
assertEquals(1, dialects.size, "exactly the honest one")
assertEquals(honest.id, dialects.single().id)
// And nothing forged was kept as an event either. The record is what a
// later archive is built from, so a forgery filed here would be one this
// member goes on to hand to everybody else.
assertEquals(
listOf(honest.id),
receiver.groupSignedEventDao().getByChatRoomId(chatRoomId).map { it.id }
)
}
@Test

View File

@@ -131,6 +131,9 @@ class SignedGroupKeyStateTest {
suspend fun item(sessionId: String) = items(sessionId).single()
suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
/** Everything the group has signed here, as the group signed it. */
suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId)
}
private suspend fun device(
@@ -506,6 +509,59 @@ class SignedGroupKeyStateTest {
}
}
/**
* What the group signed, kept as the group signed it.
*
* Every other assertion in this file reads a session's own rows, which exist
* only on a device that ran the session and are swept away with it. This
* reads the record that outlives it -- and reads it back through
* `verifies()`, so a column that failed to round-trip fails here rather than
* years later when somebody tries to hand the batch on in an archive.
*
* The path is the part no device is told. Each resolves it from the room it
* is standing in, so both arriving at `m/9420/0/0` is two independent
* derivations agreeing rather than one value being copied about.
*/
@Test
fun `a signed batch is kept as events, with the path it was signed at`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val other = device(members[1], signerIndex = 1)
val session = FrostSigningManager.proposeSigningBatch(
database = creator.db,
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
events = dialects()
)
pump(creator, other)
FrostSigningManager.approve(other.db, other.room, session.id)
pump(creator, other)
listOf(creator, other).forEach { device ->
val signed = device.signedEvents()
assertEquals(
device.items(session.id).map { it.eventId }.toSet(),
signed.map { it.id }.toSet(),
"every event the session signed should be on file as the group made it"
)
signed.forEach { event ->
assertEquals(adminRoomId, event.publicKey)
assertEquals(SharedKeyDerivation.formatPath(), event.derivationPath)
assertEquals(
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
event.pathIndices()
)
assertEquals(session.id, event.frostSigningSessionId)
assertTrue(
event.verifies(),
"${event.id} does not read back as something this room signed"
)
}
}
}
/**
* The one that catches the mistake this whole design exists to prevent.
*