feat(subgroups): schema 17 -- four nullable columns, and not one of them a foreign key

Phase 2 of docs/subgroups.md. Somewhere to put a parent, now that Phase 1 can
prove one.

| table | column | filled from | trusted? |
|---|---|---|---|
| GroupKeyState | parentChatRoomId | the state's parent tag | yes -- the certificate was checked |
| GroupKeyState | birthCertificateJson | the state's certificate tag | yes -- same |
| ChatRoom | parentChatRoomId | the verified key state | yes |
| DkgSession | parentChatRoomId | a tag on a ceremony proposal | **no** -- a screen's title |

The trust column is the point of the table and is written into the KDoc of each
one. Three of these are written only after a signature has been checked; the
fourth is an unverified claim off a wire message, and a column that mixed the two
would be a column no reader could act on. Nothing may be granted on the strength
of `DkgSession.parentChatRoomId` that would not be granted without it.

**None of the four is a foreign key, and that is the change most likely to be
"fixed" by somebody later.** `GroupKeyState`, `DkgSession` and `GroupSignedEvent`
all declare `ForeignKey(onDelete = CASCADE)` onto ChatRoom, so pointing a parent
column at ChatRoom the same way is the obvious next move. It would mean deleting
a parent room deletes every subgroup row beneath it -- and then, by their own
cascades, each subgroup's messages, participants, key state, signing sessions and
signed events. A user tidying away a group they had left would silently destroy a
group they are still in.

RESTRICT is no better: it would make a parent undeletable while any child row
exists, which is a foreign key deciding a product question. And neither would
work anyway, because a parent pointer routinely names a room this device does not
have at all -- a member of a subgroup who was never in its parent holds the id
off a certificate and nothing else. A dangling reference is the normal, expected
state here, and readers resolve it with a lookup allowed to return null.

**The certificate is stored whole, as JSON, rather than as its signature.** 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. A few hundred bytes inside an encryption removes the class. The parent
column beside it is an index into that event, never a second source of truth --
the two are written together or not at all.

Four DAO reads, each with the limits of what it answers written down.
`GroupKeyStateDao.getByParentChatRoomId`/`observe` list the children whose state
this device holds, which is *verified* but not complete -- a certified child whose
room was never created here leaves no state at all.
`ChatRoomDao.observeByParentChatRoomId` lists the ones there is something to open,
excluding soft-deleted rooms so a room the user cleared away does not reappear
because its parent lists it. `DkgSessionDao.getByParentChatRoomId` is how a member
gets back into a subgroup flow they closed the app during, since before the
certificate is signed the ceremony is the only thing on the device that knows the
flow was started.

`AutoMigration(16, 17)`: nullable additions are a shape Room migrates itself, and
17.json exports with no new foreign key on any of the three tables.

Nine tests in `SubgroupDaoJvmTest`, all on properties the compiler cannot see: a
state and a room may each name a parent this device holds no room for; deleting a
parent leaves its child, its child's key state and its child's lineage standing;
the parent lists its children newest-first and filters on *which* parent rather
than on having one; a soft-deleted subgroup drops out; and a ceremony round-trips
the parent it was opened for. 386 common tests and 673 jvm tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 23:17:18 +02:00
parent 51d6a8841a
commit f6217ce6ea
9 changed files with 6136 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 16,
version = 17,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -269,6 +269,25 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// without the index that lookup reads every message on the device. Room
// creates an index on its own.
AutoMigration(from = 15, to = 16),
// v17 adds four nullable columns for subgroups -- a group made by
// another group, which certifies it with its own quorum. See
// docs/subgroups.md.
//
// GroupKeyState gains `parentChatRoomId` and `birthCertificateJson`, the
// parent's signature over this room's id and the index into it. ChatRoom
// gains `parentChatRoomId`, a copy of the same verified value so the room
// list needs no second read. DkgSession gains one too, which is an
// unverified claim off a ceremony proposal and is read for nothing but a
// screen's title.
//
// **None of the three is a foreign key.** A self-referential cascade on
// ChatRoom would mean deleting a parent room deletes its subgroups and,
// by their own cascades, those rooms' messages, participants, key states
// and signed events. Each column also routinely names a room this device
// does not have, since a subgroup member need never have been in the
// parent, so a dangling value is the normal state. Adding nullable
// columns is a shape Room migrates itself.
AutoMigration(from = 16, to = 17),
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -66,6 +66,26 @@ 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>>
@Upsert
suspend fun upsert(chatRoom: ChatRoom)

View File

@@ -37,6 +37,22 @@ interface DkgSessionDao {
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
suspend fun getKeyHoldingSessions(): List<DkgSession>
/**
* Ceremonies opened to make a subgroup of [parentChatRoomId], newest first.
*
* How a member gets back into a subgroup flow they closed the app during:
* the child's room does not exist yet and its certificate may not be signed,
* so the ceremony is the only thing on this device that knows the flow was
* ever started.
*
* The parent named here is an unverified claim off the proposal -- see
* `DkgSession.parentChatRoomId` -- so nothing may be granted on the strength
* of appearing in this list. It decides which screen to offer, not who may
* do what.
*/
@Query("SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId ORDER BY createdAt DESC")
suspend fun getByParentChatRoomId(parentChatRoomId: String): List<DkgSession>
@Upsert
suspend fun upsert(dkgSession: DkgSession)

View File

@@ -19,6 +19,25 @@ abstract class GroupKeyStateDao {
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
/**
* 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.
*
* 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`.
*/
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<GroupKeyState>
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<GroupKeyState>>
@Upsert
abstract suspend fun upsert(groupKeyState: GroupKeyState)

View File

@@ -112,6 +112,29 @@ 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,

View File

@@ -134,6 +134,28 @@ data class DkgSession(
*/
val approvalRequestedThrough: DkgApprovalStep? = null,
/**
* The group this ceremony is being run to make a subgroup of, as the
* proposal claimed -- or null for an ordinary ceremony.
*
* **This authenticates nothing.** It is read off a tag on the proposal, and
* anybody can claim any parent. It exists for two things and neither of them
* decides anything: the ritual screen saying "a subgroup of Ekklesia" rather
* than "a shared key ceremony", and a member finding their way back into a
* flow they closed the app halfway through.
*
* The load-bearing claim is the birth certificate, two steps later, which the
* parent's own quorum signs and which any device can check against the
* parent's room id alone. Nothing may be granted on the strength of this
* column that would not be granted without it.
*
* Not a foreign key, for the reason `ChatRoom.parentChatRoomId` gives, and
* with one more of its own: the room named here need never exist on this
* device at all, since the claim is a stranger's until a certificate backs
* it.
*/
val parentChatRoomId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,

View File

@@ -93,6 +93,42 @@ data class GroupKeyState(
/** The signed event's own timestamp, so the newest state per room wins. */
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.
*
* **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.
*/
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,

View File

@@ -0,0 +1,307 @@
package press.mantra.compose.database.dao
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import press.mantra.compose.database.MantraDatabase
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.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.
*
* 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 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
* 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
* 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.
*/
class SubgroupDaoJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
@AfterTest
fun closeDb() = db.close()
private val user = KeyPair().pubKey.toHexKey()
private val parentRoom = "11".repeat(32)
private val childRoom = "22".repeat(32)
/** 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"}"""
@Test
fun `a key state keeps its parent and the certificate behind it`() = runBlocking {
seedRooms()
db.groupKeyStateDao().upsert(
keyState(
chatRoomId = childRoom,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate
)
)
val stored = db.groupKeyStateDao().getByChatRoomId(childRoom)
assertEquals(parentRoom, stored?.parentChatRoomId)
assertEquals(certificate, stored?.birthCertificateJson)
}
@Test
fun `a group that is nobody's child keeps both columns null`() = runBlocking {
seedRooms()
db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom))
val stored = db.groupKeyStateDao().getByChatRoomId(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)
}
@Test
fun `a key state 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
)
)
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
)
}
@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.chatRoomDao().delete(room(parentRoom))
// The child survives with its lineage intact and pointing at a room that
// is gone, which is exactly the dangling-reference case above. A cascade
// here would have taken the room, its key state and everything either of
// them owns.
assertNull(db.chatRoomDao().findChatRoomById(parentRoom))
assertEquals(
parentRoom,
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
)
assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
}
@Test
fun `a parent lists the states of the children it certified, newest first`() = runBlocking {
seedRooms()
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().upsert(
keyState(
chatRoomId = second,
parentChatRoomId = parentRoom,
birthCertificateJson = certificate,
announcedAt = Instant.fromEpochSeconds(2_000)
)
)
// 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
)
)
assertEquals(
listOf(second, childRoom),
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId }
)
assertEquals(
listOf(second, childRoom),
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId }
)
}
@Test
fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking {
seedRooms()
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
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)
)
)
// A room the user has cleared away must not come back because its parent
// still lists it.
assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty())
}
@Test
fun `a ceremony remembers which group it was opened to make a subgroup of`() = runBlocking {
seedRooms()
db.dkgSessionDao().upsert(session("s1", parentChatRoomId = parentRoom))
db.dkgSessionDao().upsert(session("s2"))
// Unverified, and read for nothing but a screen's title and a way back
// into an unfinished flow -- but it has to survive a round trip or there
// is no way back at all.
assertEquals(
listOf("s1"),
db.dkgSessionDao().getByParentChatRoomId(parentRoom).map { it.id }
)
assertNull(db.dkgSessionDao().getSessionById("s2")?.parentChatRoomId)
}
private suspend fun seedRooms() {
val nostrEventId = "c".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = user,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(
Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId)
)
listOf(parentRoom, childRoom).forEach { db.chatRoomDao().upsert(room(it)) }
}
private fun room(id: String) = ChatRoom(
id = id,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
)
private fun keyState(
chatRoomId: String,
parentChatRoomId: String? = null,
birthCertificateJson: String? = null,
announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000)
) = GroupKeyState(
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
thresholdPublicKey = "02" + "ab".repeat(32),
derivationPath = "m/9420/0/0",
announcedBy = user,
announcedAt = announcedAt,
parentChatRoomId = parentChatRoomId,
birthCertificateJson = birthCertificateJson,
)
private fun session(
id: String,
chatRoomId: String = parentRoom,
parentChatRoomId: String? = null
) = DkgSession(
id = id,
chatRoomId = chatRoomId,
coordinatorPublicKey = user,
userPublicKey = user,
threshold = 2,
participantCount = 3,
stage = DkgRitualStage.COLLECTING_HOST_KEYS,
hostPublicKey = user,
round1Random = "aa".repeat(32),
round2AuxRandom = "bb".repeat(32),
parentChatRoomId = parentChatRoomId,
)
}