feat(subgroups): schema 21 -- a parentage is a row, so a room can have two

Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of
two now verifies and is filed, and nothing in the app creates one until the adoption
flow lands.

`GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group
could only be founded with a parent, never adopted by a second. Those are
`GroupKeyStateParent` now, one row per parent, and nothing about what they mean
changed in the move -- including that the parent side is never a foreign key onto
`ChatRoom`, for the three reasons that entity's comment now carries.

**The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate
per child per parent", which `SubgroupLineage.of` already refuses to break; the key is
what makes it a property of the store rather than of a reader remembering. The cascade
points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must
not outlive the state that claimed it, or a room recreated at the same derived id would
inherit a parentage nobody signed for it.

**`replace` rewrites the lineage rather than merging it, and that is why it is a
transaction.** A key state is replaceable and the newest wins *wholesale*, so the set
of parents it names is the set. That is what makes a disavowal expressible at all -- a
room drops a parent by re-announcing without it -- and merged rows would leave the
dropped parent surviving the statement that dropped it. Deleting before inserting
inside one transaction also stops a reader seeing a state beside somebody else's
parents: were the writes separate, a concurrent read could land between them and get a
state with the old lineage, which looks exactly like a true answer. An older state
still changes nothing, now including its parents.

**`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one
signed statement.** `lineageOf` checks every parent and drops the whole state if any
one certificate fails -- the all-or-nothing rule generalised, for the reason it was
written: keeping the provable half would file a group as having one parent when its own
members see two. Each link is checked on its own, which is what lets a lineage be
assembled a parent at a time, and there is a comment where somebody will look for the
check that is deliberately absent: nothing requires the certificates to agree on the
set. An earlier certificate cannot name a later co-parent, so requiring agreement would
make every adoption cost a fresh quorum in every group that had already certified the
room. The child's key state is the sole authority.

**`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified
value, written by `MarmotGroupCreation` and read in production by nothing at all -- the
live answer went through `GroupKeyState`. Keeping it would mean a column holding *one*
of several parents, which is worse than unused: a cache that is wrong. Its one query,
`ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it;
the comment left in its place says where the two live answers are. `create`'s
`parentChatRoomId` parameter threaded through four files to reach the write that is now
gone, so it goes too rather than sitting dead.

`MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established,
and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows
the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead.
`BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the
host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since
`stateFrom` has always dropped such a state rather than filing it, but this is the one
moment the data is reshaped rather than re-derived and a half row copied forward would
become a parent whose certificate is the string "null". Better to leave it behind -- the
state keeps saying what the room signs with, and loses a lineage it could never prove.

`parentOf` is `parentsOf` and returns a list, through the repository to
`ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because
they are a set rather than a path -- neither is "the" parent and the order says nothing
about which came first.

Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in
both directions, a child with two parents appearing in both their lists, a
re-announcement that drops a parent and one that clears the lineage entirely, an older
state changing neither the state nor its parents, and the primary key collapsing a
repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes
"two parents are kept, each with its own certificate" -- it needed a third real group,
since the earlier draft's second parent was the child certifying itself -- beside a new
case where one bad certificate of two drops the whole state.

444 common tests, 937 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 17:50:57 +02:00
1 parent 0690932252
commit eba973331b
24 files changed
+6556 -409

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -74,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.GroupKeyStateParent
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
@@ -139,6 +140,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
GroupKeyStateParent::class,
GroupSignedEvent::class,
InReplyToRelation::class,
MantraArtifact::class,
@@ -177,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 20,
version = 21,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -321,6 +323,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// which is what they were, and any identity may drain them. The broadcast
// queue deliberately gets no owner: a signed event is anyone's to carry.
AutoMigration(from = 19, to = 20),
// v21 moves a room's parentage off two GroupKeyState columns onto the new
// GroupKeyStateParent table, so a room can be the child of more than one
// group -- founded with one and adopted by another later. It copies
// before it drops, which no AutoMigration can express, and it drops
// ChatRoom.parentChatRoomId as well: that column was a cache of the
// verified value which nothing in production ever read, and keeping it
// would mean a column holding *one* of several parents, which is worse
// than unused. See MIGRATION_20_21 and
// docs/subgroups-multiple-parents.md.
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -3,6 +3,7 @@ package press.mantra.compose.database.builder
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
import press.mantra.compose.database.migrations.MIGRATION_20_21
import press.mantra.compose.database.migrations.MIGRATION_9_10
import press.mantra.compose.database.migrations.MIGRATION_13_14
import press.mantra.compose.database.migrations.MIGRATION_14_15
@@ -19,13 +20,21 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
): press.mantra.compose.database.MantraDatabase {
return builder
// Everything else Room generates itself. These four move data rather than
// Everything else Room generates itself. These five move data rather than
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
// chat rows, 9->10 copies a session's per-event columns onto the items
// table before dropping them, 13->14 renames a column and rewrites the
// chat rows that named it the old way, and 14->15 adds a column and deletes
// the chat rows written for messages from before this device joined.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15)
// chat rows that named it the old way, 14->15 adds a column and deletes
// the chat rows written for messages from before this device joined, and
// 20->21 copies each room's single parentage onto the table that can hold
// several before dropping the columns it came from.
.addMigrations(
MIGRATION_3_4,
MIGRATION_9_10,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_20_21
)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
@@ -66,25 +66,14 @@ interface ChatRoomDao {
)
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
/**
* The subgroups of [parentChatRoomId] this device actually holds rooms for.
*
* Deliberately not the answer to "what subgroups does this group have" --
* that is `SubgroupManager.subgroupsOf`, which reads the parent's signed
* certificates and so can name a child whose room has not been created, or
* was created for somebody else. This names the ones there is something to
* open.
*
* Soft-deleted rooms are excluded like everywhere else: a room the user has
* cleared away should not reappear because its parent lists it.
*/
@Transaction
@Query(
CHAT_ROOM_WITH_LAST_MESSAGE +
"WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " +
ORDER_BY_LAST_ACTIVITY
)
fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<LocalChatRoom>>
// "The subgroups of this room" used to be a column filter here, over a
// ChatRoom.parentChatRoomId that production never read -- the live answer
// went through GroupKeyState, and SubgroupManager.subgroupsOf reads the
// parent's own certificates, which can name a child whose room this device
// does not have. The column is gone at v21, because a room can have more
// than one parent and a single column would have held one of them. The
// rooms a parent's children occupy are reached through
// GroupKeyStateDao.getByParentChatRoomId and resolved from there.
@Upsert
suspend fun upsert(chatRoom: ChatRoom)
@@ -6,6 +6,8 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
@Dao
abstract class GroupKeyStateDao {
@@ -15,6 +17,18 @@ abstract class GroupKeyStateDao {
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract fun observeByChatRoomId(chatRoomId: String): Flow<GroupKeyState?>
/**
* A state with the groups that have certified it, which is how a lineage
* should always be read -- the two are one signed statement.
*/
@Transaction
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState?
@Transaction
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract fun observeLocalByChatRoomId(chatRoomId: String): Flow<LocalGroupKeyState?>
/** Every room that signs with one ceremony's key. One, today. */
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
@@ -23,26 +37,52 @@ abstract class GroupKeyStateDao {
* Every subgroup of [parentChatRoomId] this device holds a verified state
* for.
*
* Verified is the whole of what these rows are: a state only reaches the
* table with a parent on it once its birth certificate has been checked
* against that parent's signature, so this is a list of children the parent
* really did certify rather than a list of rooms claiming a parent.
* Verified is the whole of what these rows are: a parentage only reaches the
* table once its birth certificate has been checked against that parent's
* signature, so this is a list of children the parent really did certify
* rather than a list of rooms claiming a parent.
*
* It is not the whole list. A subgroup whose room this device does not have
* leaves no state here at all, and the certificates in `GroupSignedEvent` are
* the fuller answer -- see `SubgroupManager.subgroupsOf`.
*
* A child with two parents appears in both parents' answers, which is the
* point of the join: the row that makes it this parent's child says nothing
* about who else certified it.
*/
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<GroupKeyState>
@Transaction
@Query(
"SELECT GroupKeyState.* FROM GroupKeyState " +
"JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
"WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
"ORDER BY GroupKeyState.announcedAt DESC"
)
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<LocalGroupKeyState>
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<GroupKeyState>>
@Transaction
@Query(
"SELECT GroupKeyState.* FROM GroupKeyState " +
"JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
"WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
"ORDER BY GroupKeyState.announcedAt DESC"
)
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<LocalGroupKeyState>>
/** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */
@Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List<GroupKeyStateParent>
@Upsert
abstract suspend fun upsert(groupKeyState: GroupKeyState)
@Upsert
abstract suspend fun upsertParents(parents: List<GroupKeyStateParent>)
@Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String)
/**
* Files a state, keeping the newest one per room.
* Files a state and its whole lineage, keeping the newest one per room.
*
* This is where the event's replaceable semantics actually happen. Relays
* never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is
@@ -56,13 +96,36 @@ abstract class GroupKeyStateDao {
* state agree by construction, since both derived it from the room they are
* standing in.
*
* Returns the state now on file.
* ### The lineage is replaced, not merged, and that is the whole reason this
* is a transaction
*
* A key state is replaceable and the newest wins **wholesale**, so the set of
* parents it names is the set -- not an addition to whatever was there. That
* is what makes a disavowal expressible at all: a room drops a parent by
* re-announcing without it, and if these rows were merged rather than
* rewritten the dropped parent would survive the statement that dropped it.
*
* Deleting before inserting inside one transaction is also what stops a
* reader seeing a state beside somebody else's parents. Were the two writes
* separate, a concurrent `getLocalByChatRoomId` could land between them and
* read a state with no lineage, or with the old one -- both of which look
* exactly like a true answer.
*
* Returns the state now on file, with the lineage now on file beside it.
*/
@Transaction
open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState {
open suspend fun replace(
groupKeyState: GroupKeyState,
parents: List<GroupKeyStateParent> = emptyList(),
): LocalGroupKeyState {
val known = getByChatRoomId(groupKeyState.chatRoomId)
if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known
if (known != null && known.announcedAt >= groupKeyState.announcedAt) {
return LocalGroupKeyState(
keyState = known,
parents = getParentsByChatRoomId(known.chatRoomId)
)
}
val stamped = groupKeyState.copy(
createdAt = known?.createdAt ?: groupKeyState.createdAt,
@@ -70,6 +133,13 @@ abstract class GroupKeyStateDao {
)
upsert(stamped)
return stamped
// The state's own row has to exist before its parents', because they
// reference it. Room turns foreign keys off around a migration but not
// around a transaction, so the order here is load-bearing rather than
// tidy.
deleteParentsByChatRoomId(stamped.chatRoomId)
if (parents.isNotEmpty()) upsertParents(parents)
return LocalGroupKeyState(keyState = stamped, parents = parents)
}
}
@@ -0,0 +1,86 @@
package press.mantra.compose.database.migrations
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
/**
* Moves a room's parentage off two `GroupKeyState` columns onto
* `GroupKeyStateParent`, so a room can be the child of more than one group.
*
* Not an `AutoMigration`. Room can create the table and it can drop the columns,
* but it cannot copy between them, and this migration is entirely about the copy.
*
* ### Every existing lineage is a set of one
*
* A group could only be founded with a parent, never adopted by a second, so
* every row that carries a parentage carries exactly one. Each becomes one row
* in the new table and nothing is lost -- and the rule the new primary key
* states, one certificate per child per parent, is already true of all of them.
*
* Rows with no parentage -- every group made before subgroups, and every
* top-level group made after -- contribute nothing, which is what "nobody's
* child" looks like in the new shape: no rows rather than a null.
*
* The `WHERE` takes both halves. A row with one and not the other should not
* exist, because `GroupKeyStateManager.stateFrom` has always dropped a state
* carrying a half-claim rather than filing it -- but this is the one moment the
* data is reshaped rather than re-derived, and a half row copied forward would
* become a parent whose certificate is the string "null". Better to leave such a
* row behind entirely: the state keeps saying what the room signs with, which is
* the part that matters, and loses a lineage it could never have proved.
*
* ### `ChatRoom.parentChatRoomId` goes with them, unread
*
* It was a cache of the verified value, written by `MarmotGroupCreation` and
* read -- in production -- by nothing at all; the live answer went through
* `GroupKeyState`. Keeping it would mean a column holding *one* of several
* parents, which is worse than unused: it is a cache that is wrong. So it is
* dropped rather than backfilled, and nothing is copied out of it.
*
* ### Why `DROP COLUMN` rather than a table rebuild
*
* The usual way to remove columns in SQLite -- create a new table, copy, drop
* the old one, rename -- is unsafe here and quietly so. `GroupKeyStateParent`
* references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE`
* fires cascades: with foreign keys enforced it would delete every parent row
* this migration had just written. Dropping `ChatRoom` would be far worse, since
* most of the schema cascades off it.
*
* `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and
* the columns must be free of indices and constraints -- all three are, and
* `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version
* is ours rather than the host's. Both tables keep their keys, their foreign keys
* and their indices.
*/
val MIGRATION_20_21 = object : Migration(20, 21) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" +
"`chatRoomId` TEXT NOT NULL, " +
"`parentChatRoomId` TEXT NOT NULL, " +
"`birthCertificateJson` TEXT NOT NULL, " +
"PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " +
"FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " +
"ON `GroupKeyStateParent` (`parentChatRoomId`)"
)
connection.execSQL(
"INSERT OR IGNORE INTO `GroupKeyStateParent` " +
"(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " +
"SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " +
"FROM `GroupKeyState` " +
"WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL"
)
listOf("parentChatRoomId", "birthCertificateJson").forEach { column ->
connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`")
}
connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`")
}
}
@@ -112,29 +112,6 @@ data class ChatRoom(
*/
val chronicleRequestedAt: Instant? = null,
/**
* The group this room is a subgroup of, or null for a room that is nobody's
* child.
*
* A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the
* room list and the room's own screen can answer "whose child is this"
* without a second read. It is written as the room is created or adopts its
* key state, and only from a state whose certificate passed
* `SubgroupBirthCertificateEvent.certifies`.
*
* **Never a foreign key onto `ChatRoom`.** Every other reference to a room in
* this schema cascades, and a self-referential cascade would mean deleting a
* parent silently deletes its subgroups -- and then, by their own cascades,
* those rooms' messages, participants, key states, signing sessions and
* signed events. A user tidying up a group they left would destroy a group
* they are still in. `RESTRICT` would be no better: it would make a parent
* undeletable while a child row exists, which is a foreign key deciding a
* product question. This routinely names a room this device does not have --
* see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal,
* expected state.
*/
val parentChatRoomId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
@@ -94,41 +94,16 @@ data class GroupKeyState(
val announcedAt: Instant,
/**
* The group this one is a subgroup of, or null for a group that is nobody's
* child -- which is every group made before subgroups existed.
* A lineage used to live here, as a parent id and a certificate. It is
* `GroupKeyStateParent` now, one row per parent, because a room can have
* more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about
* what it means changed in the move, including that the parent side is never
* a foreign key onto `ChatRoom`; that entity's comment carries the argument.
*
* **Not a foreign key, and that is deliberate.** The three tables around this
* one cascade off `ChatRoom`, and a self-referential cascade here would mean
* deleting a parent room deletes every subgroup's state beneath it. It could
* not be `RESTRICT` either: this routinely names a room the device does not
* have at all -- a member of a subgroup who was never in its parent holds the
* id and nothing else -- so a dangling value is the normal state and readers
* resolve it with a lookup allowed to return null.
*
* Written only from a state whose [birthCertificateJson] passed
* `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row
* where the parent's quorum really did sign for this room. See
* `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
* keeping a parentage it could not check.
* Read the two together through `LocalGroupKeyState`, never apart: a state
* and the lineage it claims are one signed statement, and
* `GroupKeyStateManager.stateFrom` believes all of it or drops all of it.
*/
val parentChatRoomId: HexKey? = null,
/**
* The parent's signed certificate, whole, as JSON -- or null when this group
* has no parent.
*
* The claim itself, where [parentChatRoomId] is only an index into it. Kept
* as the event rather than as its signature so that a reader can check it
* again from the row alone: a signature plus a rule for rebuilding the event
* it covers is a rule that breaks silently the first time the certificate's
* shape changes, and a rebuild differing by one byte hashes to an id whose
* signature fails and is indistinguishable from a forgery.
*
* Always set together with [parentChatRoomId]. One without the other is a
* state that never reached this table.
*/
val birthCertificateJson: String? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
@@ -0,0 +1,86 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* One group that has certified a room as its child, and the signature saying so.
*
* A row per parent, because a room can have more than one: it is founded with
* one and may be adopted by another later, and an adoption is the parent
* certifying plus the child re-announcing its key state with the pair added. See
* `docs/subgroups-multiple-parents.md`.
*
* These rows were two nullable columns on [GroupKeyState] while a lineage could
* only ever be one link. Nothing about their meaning changed in the move.
*
* ### The primary key is the rule
*
* `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A
* second entry for a parent already in the set is one parent asked twice and is
* refused by `SubgroupLineage.of` before it gets here; the key is what makes
* that a property of the store rather than of a reader remembering.
*
* ### Verified before it is written, every time
*
* A row exists only where `SubgroupBirthCertificateEvent.certifies` has said
* that this parent's quorum really signed for this room -- see
* `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one
* parent's certificate fails. So the presence of a row is itself the claim that
* the lineage was checked, and there is no state in which some parents are
* believed and others are not.
*
* [birthCertificateJson] is the certificate whole rather than its signature,
* so a reader can check it again from the row alone. A signature plus a rule for
* rebuilding the event it covers is a rule that breaks silently the first time
* the certificate's shape changes: a rebuild differing by one byte hashes to an
* id whose signature fails and is indistinguishable from a forgery.
*
* ### The cascade points at the state, never at the parent's room
*
* `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from
* outliving the state that claimed it -- and it is what lets a re-announcement
* that drops a parent actually drop it, since `GroupKeyStateDao.replace`
* rewrites these rows inside the same transaction.
*
* [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A
* self-referential cascade would mean deleting a parent room deletes its
* children's key states, and then by their own cascades those rooms' messages,
* participants, signing sessions and signed events -- a user tidying up a group
* they left would destroy a group they are still in. `RESTRICT` would be no
* better: it would make a parent undeletable while a child row exists, which is
* a foreign key deciding a product question. And this routinely names a room the
* device does not have at all, since a member of a subgroup need never have been
* in its parent, so a dangling value is the normal, expected state and readers
* resolve it with a lookup allowed to return null.
*/
@Entity(
primaryKeys = ["chatRoomId", "parentChatRoomId"],
foreignKeys = [
ForeignKey(
entity = GroupKeyState::class,
parentColumns = ["chatRoomId"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
// The composite primary key already indexes chatRoomId as its leftmost
// column, so only the other direction needs one: "which rooms has this
// group certified", which is how a parent finds the children whose state
// this device holds.
Index("parentChatRoomId"),
],
)
data class GroupKeyStateParent(
/** The child: the room whose key state carries this parentage. */
val chatRoomId: String,
/** The parent's room id, which is also the key its certificate verifies against. */
val parentChatRoomId: HexKey,
/** The parent's signed certificate, whole, as JSON. */
val birthCertificateJson: String,
)
@@ -0,0 +1,62 @@
package press.mantra.compose.database.model.intermdiate
import androidx.room3.Embedded
import androidx.room3.Relation
import com.vitorpamplona.quartz.nip01Core.core.Event
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.nostr.subgroup.SubgroupLineage
import press.mantra.compose.nostr.subgroup.SubgroupParentage
/**
* A room's key state with every group that has certified it as a child.
*
* The two always travel together, because a state and the lineage it claims are
* one signed statement: `GroupKeyStateManager.stateFrom` either believes all of
* it or drops all of it, so a reader holding the state without the parents would
* be holding half of something that has no halves.
*
* Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded`
* over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so
* the pure verifier can build one with no database anywhere near it.
*/
data class LocalGroupKeyState(
@Embedded
val keyState: GroupKeyState,
@Relation(
entity = GroupKeyStateParent::class,
parentColumns = ["chatRoomId"],
entityColumns = ["chatRoomId"],
)
val parents: List<GroupKeyStateParent> = emptyList(),
) {
/**
* The lineage these rows amount to, or null for a room that is nobody's
* child.
*
* Null also where the rows cannot be read back as a lineage -- a certificate
* whose JSON no longer parses, or a repeat the store should have made
* impossible. Both are the same answer here, and it is the safe one: a
* lineage that cannot be stated is not half-stated, for the reason
* `stateFrom` drops a whole state rather than keeping a parentage it could
* not check.
*
* Rebuilt rather than cached, so the canonical order and the no-repeats rule
* come from `SubgroupLineage` on every read instead of from whatever order
* the rows came back in.
*/
fun lineage(): SubgroupLineage? = SubgroupLineage.of(
parents.mapNotNull { parent ->
Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate ->
SubgroupParentage(
parentChatRoomId = parent.parentChatRoomId,
certificate = certificate
)
}
}.takeIf { it.size == parents.size } ?: return null
)
/** The groups that have certified this room, in canonical order. */
fun parentChatRoomIds(): List<String> = lineage()?.parentChatRoomIds.orEmpty()
}
@@ -55,7 +55,6 @@ class DatabaseChatRepository(
userPublicKey: HexKey,
keyPair: KeyPair,
path: List<Long>,
parentChatRoomId: HexKey?,
): MarmotGroupCreation.Outcome = MarmotGroupCreation.create(
database = database,
chatRepository = this,
@@ -66,7 +65,6 @@ class DatabaseChatRepository(
userPublicKey = userPublicKey,
keyPair = keyPair,
path = path,
parentChatRoomId = parentChatRoomId,
)
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey)
@@ -113,11 +111,11 @@ class DatabaseChatRepository(
emptyList()
}
override suspend fun parentOf(chatRoomId: String): HexKey? = try {
SubgroupManager.parentOf(database, chatRoomId)
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = try {
SubgroupManager.parentsOf(database, chatRoomId)
} catch (e: Throwable) {
logger.e("Error reading the parent of $chatRoomId", e)
null
logger.e("Error reading the parents of $chatRoomId", e)
emptyList()
}
override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
@@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
@@ -110,7 +110,7 @@ class DatabaseDkgRepository(
): Flow<List<LocalFrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?> =
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
database.groupSignedEventDao()
.observeByKind(GroupKeyStateEvent.KIND)
.map { signedEvents ->
@@ -132,7 +132,7 @@ class DatabaseDkgRepository(
subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
}
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try {
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId)
} catch (e: Throwable) {
// The room is made and usable either way; what it loses is the shortcut
@@ -9,8 +9,10 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import press.mantra.compose.nostr.subgroup.SubgroupLineage
@@ -122,18 +124,6 @@ object GroupKeyStateManager {
"Ceremony ${key.id} has produced no key for $signingRoomId to propose"
)
// The event can carry a lineage of any size; the row cannot yet. Until
// `GroupKeyState` holds a set, a caller reaching here with two parents
// would have one of them silently dropped on the way to the columns, so
// it is refused outright rather than half-kept. The tags are already
// plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets
// the wire land before the storage does.
check(lineage == null || lineage.size == 1) {
"Room $signingRoomId cannot yet propose a key state naming " +
"${lineage?.size} parents"
}
val parent = lineage?.parents?.single()
// The room this is about: the group's key walked to the admin path,
// which is that room's id. It need not exist yet, and in the flow this
// was written for it does not.
@@ -150,9 +140,7 @@ object GroupKeyStateManager {
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
announcedAt = Instant.fromEpochSeconds(createdAt),
parentChatRoomId = parent?.parentChatRoomId,
birthCertificateJson = parent?.certificate?.toJson()
announcedAt = Instant.fromEpochSeconds(createdAt)
)
check(state.verifies()) {
@@ -160,10 +148,14 @@ object GroupKeyStateManager {
}
// Refused here rather than discovered by the group. Every device that
// receives this state runs the same check in `parentageOf` and drops the
// state when it fails, so proposing one this device would not believe
// spends a quorum's attention on a statement nobody will keep.
if (parent != null) {
// receives this state runs the same checks in `lineageOf` and drops the
// state when any of them fails, so proposing one this device would not
// believe spends a quorum's attention on a statement nobody will keep.
//
// Every parent, because the receiving check is all-or-nothing: one bad
// certificate in a set of two drops the whole state, so a proposal is
// only worth making if every link in it holds.
lineage?.parents?.forEach { parent ->
check(
SubgroupBirthCertificateEvent.certifies(
event = parent.certificate,
@@ -214,18 +206,19 @@ object GroupKeyStateManager {
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
): GroupKeyState? {
val state = stateFrom(chatRoomId, innerEvent) ?: return null
): LocalGroupKeyState? {
val local = stateFrom(chatRoomId, innerEvent) ?: return null
val announced = local.keyState.chatRoomId
if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) {
if (database.chatRoomDao().findChatRoomById(announced) == null) {
logger.i(
"The group signed a key state for ${state.chatRoomId}, which does not exist " +
"The group signed a key state for $announced, which does not exist " +
"yet; holding it until it does"
)
return null
}
return database.groupKeyStateDao().replace(state)
return database.groupKeyStateDao().replace(local.keyState, local.parents)
}
/**
@@ -248,12 +241,15 @@ object GroupKeyStateManager {
* Idempotent: `replace` keeps the newest state per room, so adopting twice
* settles on the same row.
*/
suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? {
val state = signedStateFor(database, chatRoomId) ?: return null
suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? {
val local = signedStateFor(database, chatRoomId) ?: return null
logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}")
logger.i(
"Room $chatRoomId adopts the key state its group signed at " +
"${local.keyState.announcedAt}, naming ${local.parents.size} parents"
)
return database.groupKeyStateDao().replace(state)
return database.groupKeyStateDao().replace(local.keyState, local.parents)
}
/**
@@ -266,7 +262,7 @@ object GroupKeyStateManager {
* the ritual screen gates the "create the room" button on, and what [adopt]
* files once there is a room to file it against.
*/
suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? =
stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId)
/**
@@ -295,7 +291,7 @@ object GroupKeyStateManager {
* arrive can ask the same question of each emission -- which is how the
* ceremony screen knows the group has finished agreeing.
*/
fun stateAmong(signedEvents: List<GroupSignedEvent>, chatRoomId: String): GroupKeyState? =
fun stateAmong(signedEvents: List<GroupSignedEvent>, chatRoomId: String): LocalGroupKeyState? =
signedAmong(signedEvents, chatRoomId)?.second
/**
@@ -310,7 +306,7 @@ object GroupKeyStateManager {
private fun signedAmong(
signedEvents: List<GroupSignedEvent>,
chatRoomId: String
): Pair<GroupSignedEvent, GroupKeyState>? =
): Pair<GroupSignedEvent, LocalGroupKeyState>? =
signedEvents
.asSequence()
.filter { it.kind == GroupKeyStateEvent.KIND }
@@ -319,7 +315,7 @@ object GroupKeyStateManager {
// every room at once, so a state that names no room names nothing.
.filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId }
.mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } }
.maxByOrNull { (_, state) -> state.announcedAt }
.maxByOrNull { (_, local) -> local.keyState.announcedAt }
/**
* The state an event amounts to, or null if it amounts to none.
@@ -358,20 +354,21 @@ object GroupKeyStateManager {
* names no room at all, and appears in the logs.
*/
/**
* A parentage that was checked, wrapping a value that is allowed to be
* A lineage that was checked, wrapping a value that is allowed to be
* absent.
*
* Three outcomes have to be told apart and a bare nullable can only carry
* two. A state may claim no parent, which is every group made before
* subgroups and every top-level group made after; it may claim one and prove
* it; or it may claim one it cannot prove, which is not a state with a field
* missing but a device asserting a relationship the parent never agreed to.
* The third has to stop [stateFrom] rather than be flattened into the first.
* subgroups and every top-level group made after; it may claim some and
* prove them all; or it may claim some it cannot prove, which is not a state
* with a field missing but a device asserting a relationship a parent never
* agreed to. The third has to stop [stateFrom] rather than be flattened into
* the first.
*
* So `null` here means refused, and a present [value] of `null` means none
* claimed.
*/
private class Parentage(val value: SubgroupParentage?)
private class Lineage(val value: SubgroupLineage?)
/**
* The parentage an event actually establishes, or null if it claims one it
@@ -387,12 +384,12 @@ object GroupKeyStateManager {
* not a state with one field wrong, and half-believing it would file a group
* as top-level that its own members will see as a subgroup.
*/
private fun parentageOf(
private fun lineageOf(
announced: String,
thresholdPublicKey: HexKey,
innerEvent: Event
): Parentage? {
if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null)
): Lineage? {
if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null)
// Both or neither, per parent, and no parent twice -- all of which
// `parseLineage` decides. A parent named with no certificate is a claim
@@ -410,55 +407,57 @@ object GroupKeyStateManager {
return null
}
// One parent is all a row can hold until `GroupKeyState` carries a set.
// Refused rather than truncated: taking the first of two would file a
// lineage the group did not sign and leave the device believing a group
// has one parent when its own members see two. Nothing in this build
// emits a second, so this fires only on a state from a newer one.
if (lineage.size > 1) {
logger.w(
"Key state for $announced claims ${lineage.size} parents " +
"(${lineage.parentChatRoomIds}), which this build cannot file; dropping"
)
return null
// Every parent, or none of them. One bad certificate drops the whole
// state rather than the one pair, for the reason the all-or-nothing rule
// was written: a state that claims a lineage it cannot back is a device
// asserting a relationship somebody never agreed to, and keeping the
// provable half would file a group as having one parent when its own
// members see two. A set that is partly believed is not a weaker set.
lineage.parents.forEach { parentage ->
val parentChatRoomId = parentage.parentChatRoomId
val certificate = parentage.certificate
// The parent's own quorum, over this very room's id. Everything that
// makes a lineage worth anything is in here -- see
// `SubgroupBirthCertificateEvent.certifies`, which needs no lookup,
// no key it has to be told, and no knowledge of the other parents.
// Each link is checked on its own, which is why a lineage can be
// assembled a parent at a time.
if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
logger.w(
"Key state for $announced carries no certificate by $parentChatRoomId for " +
"that room; dropping"
)
return null
}
// Belt and braces: the id already derives from this key, and the
// certificate's id already derives from the key it names, so two
// certificates for one room cannot name two keys. Stated anyway
// because the two facts live in different places and a future change
// to either should have to notice this.
val certifiedKey =
SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
logger.w(
"Key state for $announced names $thresholdPublicKey and the certificate " +
"by $parentChatRoomId names $certifiedKey; dropping"
)
return null
}
}
val parentage = lineage.parents.single()
val parentChatRoomId = parentage.parentChatRoomId
val certificate = parentage.certificate
// The parent's own quorum, over this very room's id. Everything that
// makes a lineage worth anything is in here -- see
// `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no
// key it has to be told.
if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
logger.w(
"Key state for $announced carries no certificate by $parentChatRoomId for " +
"that room; dropping"
)
return null
}
// Belt and braces: the id already derives from this key, and the
// certificate's id already derives from the key it names, so two
// certificates for one room cannot name two keys. Stated anyway because
// the two facts live in different places and a future change to either
// should have to notice this.
val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
logger.w(
"Key state for $announced names $thresholdPublicKey and its certificate names " +
"$certifiedKey; dropping"
)
return null
}
return Parentage(
SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate)
)
// Deliberately not checked: whether the parents know about each other.
// An earlier certificate cannot name a later co-parent -- the first
// parent signed before the second was a possibility -- so requiring them
// to agree on the set would make every adoption cost a fresh quorum in
// every group that had already certified this room. The child's key state
// is the sole authority on the set; each parent has a veto on being added
// and none on who else is in it. See docs/subgroups-multiple-parents.md.
return Lineage(lineage)
}
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
@@ -491,7 +490,7 @@ object GroupKeyStateManager {
return null
}
val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null
val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null
val state = GroupKeyState(
chatRoomId = announced,
@@ -499,9 +498,7 @@ object GroupKeyStateManager {
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = innerEvent.pubKey,
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt),
parentChatRoomId = parent.value?.parentChatRoomId,
birthCertificateJson = parent.value?.certificate?.toJson()
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
)
// Half the trust model, in one line, and the half that does not care who
@@ -514,6 +511,15 @@ object GroupKeyStateManager {
return null
}
return state
return LocalGroupKeyState(
keyState = state,
parents = lineage.value?.parents?.map {
GroupKeyStateParent(
chatRoomId = announced,
parentChatRoomId = it.parentChatRoomId,
birthCertificateJson = it.certificate.toJson()
)
}.orEmpty()
)
}
}
@@ -93,10 +93,6 @@ object MarmotGroupCreation {
* appended to it, because MIP-01 has no field for one and the path is what
* rebuilds the `TweakCache` a signing session needs.
*
* [parentChatRoomId] is written onto the room when this is a subgroup. It is
* the verified value off the child's own key state -- never a claim off a
* proposal.
*
* [name] is put under [MarmotGroupName] here rather than trusted from the
* caller. Every Marmot room on a device is a `#name`, and this is the funnel
* both flows that make one already go through, so it is the one place the
@@ -112,7 +108,6 @@ object MarmotGroupCreation {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
parentChatRoomId: HexKey? = null,
keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT
): Outcome {
chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) }
@@ -163,15 +158,6 @@ object MarmotGroupCreation {
mlsGroupState = group.saveState().encodeTls().toHex()
) ?: return Outcome.Failed("Couldn't create the group. Please try again.")
if (parentChatRoomId != null) {
// Written after the room exists rather than passed into its creation,
// because `getOrCreateChatRoom` is shared with every other way a room
// appears and none of them has a parent to hand it.
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId)
)
}
// The room comes into existence already knowing what it signs with. The
// group agreed that before any of this ran and this device has been
// holding the signed statement since; this is simply the first moment
@@ -410,18 +410,26 @@ object SubgroupManager {
.sortedByDescending { it.certificate.createdAt }
/**
* The group a room is a subgroup of, verified, or null if it is nobody's
* child.
* Every group that has certified a room as its child, verified, in canonical
* order -- empty for a room that is nobody's child.
*
* Off the `GroupKeyState`, which only carries a parent whose certificate was
* checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this
* device does not hold answers null even when it really is a subgroup, which
* is the position a member welcomed in after the founding is in; the
* unverified hint in the room's own description is the most they get, and it
* must never be written here.
* Off `GroupKeyStateParent`, whose rows exist only where the certificate was
* checked against that parent's own signature -- see
* `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
* keeping a lineage it could not check. So every id here is a group that
* really did certify this room.
*
* More than one is the ordinary case now, not a corner: a group is founded
* with one parent and may be adopted by another later. The order is the
* lineage's own and says nothing about which came first.
*
* A room whose state this device does not hold answers empty even when it
* really is a subgroup, which is the position a member welcomed in after the
* founding is in. Nothing unverified may be written here.
*/
suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? =
database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId
suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List<HexKey> =
database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds()
.orEmpty()
/**
* Files a certificate that arrived, or drops it and says why.
@@ -70,14 +70,16 @@ interface ChatRepository {
suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup>
/**
* The group this room is a subgroup of, verified, or null if it is nobody's
* child.
* Every group that has certified this room as its child, verified, in
* canonical order -- empty if it is nobody's child.
*
* Off the room's `GroupKeyState`, which only carries a parent whose
* certificate was checked. A member welcomed in after the founding holds no
* state and gets null here even when the room really is a subgroup.
* Off `GroupKeyStateParent`, whose rows exist only where the certificate was
* checked. More than one is ordinary: a group is founded with one parent and
* may be adopted by another later. A member welcomed in after the founding
* holds no state and gets an empty list even when the room really is a
* subgroup.
*/
suspend fun parentOf(chatRoomId: String): HexKey?
suspend fun parentsOf(chatRoomId: String): List<HexKey>
/**
* One event the group signed, as the group signed it, or null when this
@@ -203,7 +205,6 @@ interface ChatRepository {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
parentChatRoomId: HexKey? = null,
): MarmotGroupCreation.Outcome
/**
@@ -279,7 +280,7 @@ interface ChatRepository {
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
emptyList()
override suspend fun parentOf(chatRoomId: String): HexKey? = null
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = emptyList()
override suspend fun groupSignedEvent(
chatRoomId: String,
@@ -386,7 +387,6 @@ interface ChatRepository {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List<Long>,
parentChatRoomId: HexKey?,
): MarmotGroupCreation.Outcome =
MarmotGroupCreation.Outcome.Failed("No database")
@@ -3,7 +3,7 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
@@ -149,10 +149,10 @@ interface DkgRepository {
* a finished subgroup with its key state signed, its certificate signed, and
* no way to create the room -- the lookup found no ceremony at all.
*/
fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?>
fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?>
/** Files the state the group signed for a room that now exists. */
suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState?
/**
* Asks the parent's quorum to certify the subgroup this ceremony produced.
@@ -242,10 +242,10 @@ interface DkgRepository {
chatRoomId: String
): Flow<List<LocalFrostSigningSession>> = flowOf(emptyList())
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?> =
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
flowOf(null)
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null
override suspend fun proposeBirthCertificate(
parentChatRoomId: String,
@@ -259,8 +259,14 @@ fun ChatRoomDetailScreen(
// nobody's child, and absent -- rather than guessed --
// on a subgroup this device holds no key state for,
// since an unverified lineage is not one worth showing.
chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId ->
item {
//
// One card per parent, because a room can have more
// than one: founded with one and adopted by another
// later. They are a set rather than a path -- neither
// is "the" parent and the order says nothing about
// which came first -- so each gets an equal row.
chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId ->
item(key = "parent-$parentChatRoomId") {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
@@ -81,7 +81,7 @@ class ChatRoomDetailViewModel(
groupKeyState = chatRepository.groupKeyState(chatRoomId),
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
parentChatRoomId = chatRepository.parentOf(chatRoomId),
parentChatRoomIds = chatRepository.parentsOf(chatRoomId),
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
chatRepository.canSign(chatRoomId),
)
@@ -607,11 +607,7 @@ class DkgRitualViewModel(
},
adminPublicKeys = members,
userPublicKey = activeUserPublicKey,
keyPair = keyPair,
// Verified by the time it is written: the key state this room was
// gated on carries the certificate, and `stateFrom` refused it
// otherwise.
parentChatRoomId = subgroupParent
keyPair = keyPair
)
isActionPending.value = false
@@ -49,7 +49,7 @@ sealed interface ChatRoomDetailUIState {
* founding holds no state and so sees nothing here, even in a room that
* really is a subgroup.
*/
val parentChatRoomId: HexKey? = null,
val parentChatRoomIds: List<HexKey> = emptyList(),
/**
* Whether this device could open a subgroup here at all.
*
@@ -75,6 +75,15 @@ class GroupKeyStateTest {
threshold = threshold
)
/** A third group, for the lineages that name two parents. */
private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3")
),
nParticipants = participants,
threshold = threshold
)
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
/** The other group's key, for the states that name the wrong one. */
@@ -307,15 +316,15 @@ class GroupKeyStateTest {
fun `a state the group signed about the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
assertEquals(chatRoomId, state?.chatRoomId)
assertEquals("ceremony-1", state?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.derivationPath)
assertEquals(chatRoomId, state?.keyState?.chatRoomId)
assertEquals("ceremony-1", state?.keyState?.dkgSessionId)
assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey)
assertEquals("m/9420/0/0", state?.keyState?.derivationPath)
// Attribution and ordering come off the event, not off the clock. The
// author is the room itself now, which is the whole point: nobody in
// particular said this.
assertEquals(chatRoomId, state?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
assertEquals(chatRoomId, state?.keyState?.announcedBy)
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt)
}
@Test
@@ -412,8 +421,8 @@ class GroupKeyStateTest {
)
)
assertNotEquals(chatRoomId, state?.chatRoomId)
assertEquals(siblingRoom, state?.chatRoomId)
assertNotEquals(chatRoomId, state?.keyState?.chatRoomId)
assertEquals(siblingRoom, state?.keyState?.chatRoomId)
}
@Test
@@ -475,7 +484,8 @@ class GroupKeyStateTest {
assertEquals(
chatRoomId,
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))
?.keyState?.chatRoomId
)
}
@@ -760,6 +770,23 @@ class GroupKeyStateTest {
/** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */
private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex
/** A second, unrelated parent's room -- the group that adopts the child later. */
private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex
/** A lineage of both parents, each with its own group's real signature. */
private fun bothParents() = SubgroupLineage.of(
listOf(
SubgroupParentage(parentChatRoomId, birthCertificate()),
SubgroupParentage(
secondParentChatRoomId,
birthCertificate(
parent = secondParentChatRoomId,
author = secondParentKeyMaterial
)
)
)
)
/** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */
private fun birthCertificate(
subgroupChatRoomId: String = chatRoomId,
@@ -799,18 +826,26 @@ class GroupKeyStateTest {
fun `a state whose parent certified it keeps the parent and the certificate`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent())
assertEquals(parentChatRoomId, state?.parentChatRoomId)
assertEquals(birthCertificate().toJson(), state?.birthCertificateJson)
assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds())
assertEquals(
birthCertificate().toJson(),
state?.parents?.single()?.birthCertificateJson
)
// And the row points back at the child, which is what makes it a row
// rather than a column.
assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId)
}
@Test
fun `a state claiming no parent keeps both fields null`() {
fun `a state claiming no parent carries no parent rows at all`() {
// Every group made before subgroups existed, and every top-level group
// made after. Null here is the ordinary case, not missing data.
// made after. No rows is the ordinary case, not missing data -- and it is
// one way of saying "nobody's child" rather than two, which is why a
// lineage of none is null rather than empty.
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
assertNull(state?.parentChatRoomId)
assertNull(state?.birthCertificateJson)
assertEquals(emptyList(), state?.parents)
assertNull(state?.lineage())
}
@Test
@@ -924,17 +959,11 @@ class GroupKeyStateTest {
// ids and then all the certificates, such a reader would pair one
// parent's id with another's certificate, fail `certifies`, and drop the
// whole state -- losing the row that says which share signs for the room.
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
val tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
lineage = SubgroupLineage.of(
listOf(
SubgroupParentage(parentChatRoomId, birthCertificate()),
SubgroupParentage(chatRoomId, second)
)
)
lineage = bothParents()
)
val names = tags.map { it[0] }.filter {
@@ -960,11 +989,43 @@ class GroupKeyStateTest {
}
@Test
fun `a state claiming two parents is dropped rather than truncated`() {
// Until a row can hold a set. Taking the first of two would file a
// lineage the group did not sign, and leave this device believing a group
// has one parent while its own members see two.
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
fun `a state claiming two parents keeps both, each with its own certificate`() {
// Two groups, two quorums, two signatures over the same child's id. The
// second one signed later and could not have named the first as a
// co-parent, which is why nothing requires the certificates to agree on
// the set -- the child's key state is the sole authority on it.
val state = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
lineage = bothParents()
)
)
val local = GroupKeyStateManager.stateFrom(chatRoomId, state)
assertEquals(
listOf(parentChatRoomId, secondParentChatRoomId).sorted(),
local?.parentChatRoomIds()?.sorted()
)
assertEquals(2, local?.parents?.size)
// Each row carries the certificate of the parent it names, not whichever
// one came first off the tags.
local?.parents?.forEach { parent ->
assertEquals(
parent.parentChatRoomId,
Event.fromJson(parent.birthCertificateJson).pubKey,
"each parentage row keeps its own parent's certificate"
)
}
}
@Test
fun `one bad certificate in a set of two drops the whole state`() {
// All or nothing, generalised. Keeping the provable half would file a
// group as having one parent when its own members see two, and a set
// that is partly believed is not a weaker set.
val state = signedKeyState(
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = chatRoomId,
@@ -973,7 +1034,16 @@ class GroupKeyStateTest {
lineage = SubgroupLineage.of(
listOf(
SubgroupParentage(parentChatRoomId, birthCertificate()),
SubgroupParentage(chatRoomId, second)
// Really signed by the second parent, but for a different
// child -- so it certifies something, just not this.
SubgroupParentage(
secondParentChatRoomId,
birthCertificate(
subgroupChatRoomId = otherKey,
parent = secondParentChatRoomId,
author = secondParentKeyMaterial
)
)
)
)
)
@@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.DkgRitualStage
/**
* What schema 17 has to survive, and the one thing it must refuse to do.
* What schema 21 has to survive, and the two things it must refuse to do.
*
* Four nullable columns say a group is somebody's child. Three properties of
* them are invisible to the compiler and each one is a way subgroups could break
* quietly.
* A room's parentage is a table now rather than two nullable columns, because a
* room can be the child of more than one group -- founded with one and adopted by
* another later. Four properties of that are invisible to the compiler and each
* one is a way a lineage could break quietly.
*
* A parent pointer **may name a room this device does not have**. A member of a
* subgroup who was never in its parent holds the id and nothing else, so a
* dangling value is the normal state rather than an error -- and if any of these
* columns were ever given a foreign key, that member's rows would stop being
* dangling value is the normal state rather than an error -- and if the parent
* side were ever given a foreign key, that member's rows would stop being
* writable at all.
*
* Deleting a parent **must not touch its children**. Every other reference to a
* room in this schema cascades. A self-referential cascade here would mean a user
* room in this schema cascades. A self-referential cascade would mean a user
* tidying away a group they had left silently destroying a group they are still
* in, together with its messages, participants, key state and signed work.
*
* And the two `GroupKeyState` columns **travel together**. One without the other
* is a state whose parentage cannot be rechecked from the row, which is the whole
* reason the certificate is stored whole rather than as a signature.
* A state's lineage **is replaced wholesale, never merged**. A key state is
* replaceable and the newest wins, so the set of parents it names is the set.
* That is what makes a disavowal expressible at all: a room drops a parent by
* re-announcing without it, and merged rows would leave the dropped parent
* surviving the statement that dropped it.
*
* And a lineage **does not outlive the state that claimed it**, which is the one
* cascade here that is wanted.
*/
class SubgroupDaoJvmTest {
@@ -59,83 +66,72 @@ class SubgroupDaoJvmTest {
/** A parent this device has never held a room for. */
private val absentParent = "33".repeat(32)
private val certificate =
"""{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}"""
private fun certificate(by: String) =
"""{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" +
""""tags":[],"content":"cc","sig":"dd"}"""
@Test
fun `a key state keeps its parent and the certificate behind it`() = runBlocking {
fun `a key state keeps every parent and the certificate behind each`() = runBlocking {
seedRooms()
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = childRoom,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(parentRoom), parentage(absentParent))
)
val stored = db.groupKeyStateDao().getByChatRoomId(childRoom)
val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom)
assertEquals(parentRoom, stored?.parentChatRoomId)
assertEquals(certificate, stored?.birthCertificateJson)
assertEquals(
listOf(parentRoom, absentParent).sorted(),
stored?.parents?.map { it.parentChatRoomId }?.sorted()
)
assertEquals(
certificate(parentRoom),
stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson
)
}
@Test
fun `a group that is nobody's child keeps both columns null`() = runBlocking {
fun `a group that is nobody's child carries no parent rows`() = runBlocking {
seedRooms()
db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom))
db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom))
val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom)
val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom)
// Every group made before subgroups existed reads back this way, and so
// does every top-level group made after. Null here is not missing data.
assertNull(stored?.parentChatRoomId)
assertNull(stored?.birthCertificateJson)
// does every top-level group made after. No rows is not missing data, and
// it is one way of saying "nobody's child" rather than two.
assertEquals(emptyList(), stored?.parents)
assertNull(stored?.lineage())
}
@Test
fun `a key state may name a parent this device holds no room for`() = runBlocking {
fun `a parentage may name a parent this device holds no room for`() = runBlocking {
seedRooms()
// The position a subgroup member who was never in the parent is in: they
// hold the id off a certificate and nothing else. If this column were a
// foreign key their state could not be written at all.
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = childRoom,
parentChatRoomId = absentParent,
birthCertificateJson = certificate
)
// hold the id off a certificate and nothing else. Were the parent side a
// foreign key their lineage could not be written at all.
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(absentParent))
)
assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
assertNull(db.chatRoomDao().findChatRoomById(absentParent))
}
@Test
fun `a room may name a parent this device holds no room for`() = runBlocking {
seedRooms()
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent))
assertEquals(
absentParent,
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
listOf(absentParent),
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
assertNull(db.chatRoomDao().findChatRoomById(absentParent))
}
@Test
fun `deleting a parent room leaves its subgroup standing`() = runBlocking {
seedRooms()
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = childRoom,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(parentRoom))
)
db.chatRoomDao().delete(room(parentRoom))
@@ -146,10 +142,27 @@ class SubgroupDaoJvmTest {
// them owns.
assertNull(db.chatRoomDao().findChatRoomById(parentRoom))
assertEquals(
parentRoom,
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
listOf(parentRoom),
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
}
@Test
fun `deleting the child takes its lineage with it`() = runBlocking {
seedRooms()
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(parentRoom))
)
// The one cascade that is wanted: a lineage must not outlive the state
// that claimed it, or a room recreated at the same derived id would
// inherit a parentage nobody signed for it.
db.chatRoomDao().delete(room(childRoom))
assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom))
assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
}
@Test
@@ -159,73 +172,130 @@ class SubgroupDaoJvmTest {
val second = "44".repeat(32)
db.chatRoomDao().upsert(room(second))
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = childRoom,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate,
announcedAt = Instant.fromEpochSeconds(1_000)
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
listOf(parentage(parentRoom))
)
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = second,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate,
announcedAt = Instant.fromEpochSeconds(2_000)
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)),
listOf(parentage(parentRoom, child = second))
)
// Another group's child, to prove the filter is on the parent rather
// than on having a parent at all.
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = parentRoom,
parentChatRoomId = absentParent,
birthCertificateJson = certificate
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = parentRoom),
listOf(parentage(absentParent, child = parentRoom))
)
assertEquals(
listOf(second, childRoom),
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId }
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId }
)
assertEquals(
listOf(second, childRoom),
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId }
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first()
.map { it.keyState.chatRoomId }
)
}
@Test
fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking {
fun `a child with two parents appears in both their lists`() = runBlocking {
seedRooms()
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
db.chatRoomDao().upsert(room(absentParent))
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(parentRoom), parentage(absentParent))
)
val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first()
assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id })
// A child that was certified but whose room was never created here shows
// up nowhere in this query. That is the difference between it and
// SubgroupManager.subgroupsOf, which reads the parent's certificates.
assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty())
}
@Test
fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking {
seedRooms()
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
db.chatRoomDao().upsert(
room(childRoom).copy(
parentChatRoomId = parentRoom,
deletedAt = Instant.fromEpochSeconds(9_000)
// The row that makes it this parent's child says nothing about who else
// certified it, which is the whole point of the join.
listOf(parentRoom, absentParent).forEach { parent ->
assertEquals(
listOf(childRoom),
db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId },
"$parent should list the child it certified"
)
}
}
@Test
fun `a newer state replaces the lineage rather than adding to it`() = runBlocking {
seedRooms()
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
listOf(parentage(parentRoom), parentage(absentParent))
)
// A room the user has cleared away must not come back because its parent
// still lists it.
assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty())
// A disavowal: the child re-announces without one of them. Were these
// rows merged, the dropped parent would survive the statement dropping
// it -- and a parent nobody can drop is a parent for life.
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
listOf(parentage(parentRoom))
)
assertEquals(
listOf(parentRoom),
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty())
}
@Test
fun `a re-announcement to nobody's child clears the lineage`() = runBlocking {
seedRooms()
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
listOf(parentage(parentRoom))
)
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000))
)
assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
}
@Test
fun `an older state changes neither the state nor the lineage on file`() = runBlocking {
seedRooms()
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
listOf(parentage(parentRoom))
)
// A session replayed after a crash must not walk the room back to a
// state it has already moved past -- and must not walk its lineage back
// either, which is new: the parents are part of the statement now.
val kept = db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
listOf(parentage(absentParent))
)
assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt)
assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId })
assertEquals(
listOf(parentRoom),
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
}
@Test
fun `one parent gets one row, whatever a caller tries`() = runBlocking {
seedRooms()
// The primary key is the rule "one certificate per child per parent".
// `SubgroupLineage.of` refuses a repeat long before here; this is the
// store saying so too, so the rule does not rest on a reader remembering.
db.groupKeyStateDao().replace(
keyState(chatRoomId = childRoom),
listOf(parentage(parentRoom), parentage(parentRoom))
)
assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size)
}
@Test
@@ -271,10 +341,15 @@ class SubgroupDaoJvmTest {
mlsGroupState = null,
)
private fun parentage(parentChatRoomId: String, child: String = childRoom) =
GroupKeyStateParent(
chatRoomId = child,
parentChatRoomId = parentChatRoomId,
birthCertificateJson = certificate(parentChatRoomId),
)
private fun keyState(
chatRoomId: String,
parentChatRoomId: String? = null,
birthCertificateJson: String? = null,
announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000)
) = GroupKeyState(
chatRoomId = chatRoomId,
@@ -283,8 +358,6 @@ class SubgroupDaoJvmTest {
derivationPath = "m/9420/0/0",
announcedBy = user,
announcedAt = announcedAt,
parentChatRoomId = parentChatRoomId,
birthCertificateJson = birthCertificateJson,
)
private fun session(
@@ -194,7 +194,7 @@ class SignedGroupKeyStateTest {
/** The one event a session of one signs. */
suspend fun item(sessionId: String) = items(sessionId).single()
suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId)
/** Everything the group has signed here, as the group signed it. */
suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId)
@@ -515,13 +515,13 @@ class SignedGroupKeyStateTest {
listOf(creator, other).forEach { device ->
val state = device.keyState()
assertNotNull(state, "every signer should hold the state the group signed")
assertEquals(thresholdPublicKey, state.thresholdPublicKey)
assertEquals(ceremonyId, state.dkgSessionId)
assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath)
assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey)
assertEquals(ceremonyId, state.keyState.dkgSessionId)
assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath)
// Signed by the room, so attributed to the room -- nobody in
// particular said this.
assertEquals(adminRoomId, state.announcedBy)
assertTrue(state.verifies())
assertEquals(adminRoomId, state.keyState.announcedBy)
assertTrue(state.keyState.verifies())
}
}
@@ -589,7 +589,7 @@ class SignedGroupKeyStateTest {
absent.keyState(),
"a member the quorum did not need still learns what the room signs with"
)
assertEquals(adminRoomId, absent.keyState()?.announcedBy)
assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy)
}
// ---- The key state, signed before the room exists ----------------------
@@ -722,22 +722,22 @@ class SignedGroupKeyStateTest {
val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId)
assertNotNull(adopted, "a room should adopt the state its group signed for it")
assertEquals(adminRoomId, adopted.chatRoomId)
assertEquals(thresholdPublicKey, adopted.thresholdPublicKey)
assertEquals(ceremonyId, adopted.dkgSessionId)
assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath)
assertEquals(adminRoomId, adopted.announcedBy)
assertTrue(adopted.verifies())
assertEquals(adminRoomId, adopted.keyState.chatRoomId)
assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey)
assertEquals(ceremonyId, adopted.keyState.dkgSessionId)
assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath)
assertEquals(adminRoomId, adopted.keyState.announcedBy)
assertTrue(adopted.keyState.verifies())
// On file, not merely returned. Compared field by field because the
// store keeps whole seconds and the row in hand does not.
val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId)
assertNotNull(stored)
assertEquals(adopted.chatRoomId, stored.chatRoomId)
assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey)
assertEquals(adopted.dkgSessionId, stored.dkgSessionId)
assertEquals(adopted.derivationPath, stored.derivationPath)
assertEquals(adopted.announcedAt, stored.announcedAt)
assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId)
assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey)
assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId)
assertEquals(adopted.keyState.derivationPath, stored.derivationPath)
assertEquals(adopted.keyState.announcedAt, stored.announcedAt)
}
}
@@ -763,14 +763,14 @@ class SignedGroupKeyStateTest {
assertNotNull(first)
assertNotNull(second)
assertEquals(first.thresholdPublicKey, second.thresholdPublicKey)
assertEquals(first.dkgSessionId, second.dkgSessionId)
assertEquals(first.derivationPath, second.derivationPath)
assertEquals(first.announcedAt, second.announcedAt)
assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey)
assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId)
assertEquals(first.keyState.derivationPath, second.keyState.derivationPath)
assertEquals(first.keyState.announcedAt, second.keyState.announcedAt)
// The second adopt is a no-op rather than a rewrite: `replace` keeps the
// state already on file when nothing newer has been signed. To whole
// seconds, which is all the store keeps.
assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds)
assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds)
}
@Test
@@ -1380,9 +1380,9 @@ class SignedGroupKeyStateTest {
val state = creator.keyState()
assertNotNull(state, "a room off the default path must still reach a signed state")
assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath)
assertEquals(siblingRoomId, state.announcedBy)
assertTrue(state.verifies())
assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath)
assertEquals(siblingRoomId, state.keyState.announcedBy)
assertTrue(state.keyState.verifies())
}
@Test
@@ -1572,10 +1572,11 @@ class SignedGroupKeyStateTest {
// The whole join. Neither device was sent a row; both derived the
// event from their own items, both re-ran `certifies` against the
// parent's id, and both wrote the same two columns.
assertEquals(parentRoomId, state.parentChatRoomId)
assertEquals(certificate.toJson(), state.birthCertificateJson)
assertTrue(state.verifies())
// parent's id, and both wrote the same parentage row.
assertEquals(listOf(parentRoomId), state.parentChatRoomIds())
assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson)
assertEquals(adminRoomId, state.parents.single().chatRoomId)
assertTrue(state.keyState.verifies())
}
}
@@ -1626,8 +1627,8 @@ class SignedGroupKeyStateTest {
// does every top-level group made after.
listOf(creator, other).forEach { device ->
val state = assertNotNull(device.keyState())
assertNull(state.parentChatRoomId)
assertNull(state.birthCertificateJson)
assertEquals(emptyList(), state.parents)
assertNull(state.lineage())
}
}