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

@@ -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.
*