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

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,