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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user