feat(subgroups): schema 19 -- the two things a ceremony's own room used to answer
A subgroup's ChillDKG is about to move out of the sibling NIP-17 room derived from its admins and into the parent's Marmot room. That room answers two questions the sibling room answered for free, and it answers both of them wrong. **Who the ceremony is with.** The sibling room's membership *was* the participant set -- it was derived from it -- so `memberPublicKeys(localChatRoom)` was the admin list, and three callers read it that way: `broadcast`'s p-tags, the birth certificate's `adminPublicKeys`, and the members `MarmotGroupCreation` welcomes into the child. The parent's room is a superset, so each of those would silently name every person the subgroup is *not*. `participantPublicKeys` is the set the ceremony was opened on, sorted and comma-joined the way `publicShares` already stores a list. **What the room it makes is called.** The sibling room carried the subgroup's name as its subject, put there by the coordinator and delivered to everyone else by `getOrCreateNip17ChatRoom` reading the proposal's `subject` tag. A ceremony in the parent's room has no room of its own to be named, and the parent's name is the one name a child must not take -- `proposeBirthCertificate` normalises this string into the certificate the parent's quorum signs, and `MarmotGroupCreation` normalises it again onto the room. `subject` is where it lands instead. Both are read off the proposal, which has carried both since `7bccf243` -- the p-tags and the `subject` tag. Nothing new goes on the wire; what changes is where it is kept. Both are nullable and both fall back to the room, which is correct for every row written before this: a ceremony in its own NIP-17 room ran over exactly that room's members and was named after it. **Sorted, because two devices assemble the set differently.** The coordinator builds it from a picker; every other device builds it from the proposal's p-tags plus the sender. `DkgSession.formatParticipants` sorts so those are the same string, which is what makes it something a query can match on. Two queries, both of which exist because `DkgSession.chatRoomId` is about to stop identifying a ceremony: `getLatestSubgroupSessionFor(room, parent, admins)` is what "may I open another" means for a subgroup once the parent's room hosts every one the group ever runs. `getLatestSessionFor(room, parent)` cannot answer it -- two subgroups of one parent share both columns -- and scoping on the room alone would refuse a group's second subgroup on the strength of its first. `getLatestOwnSessionForChatRoom(room)` is `getLatestSessionForChatRoom` with `parentChatRoomId IS NULL`. It exists for `FrostSigningManager.completedKey`'s last fallback, which is the one every parent member welcomed after the group's own ceremony lands on: the parent's room will hold a *completed* ceremony whose key is the child's, and a ceremony run to make a subgroup is never the room's own key. The claim is unverified, but the only direction it can be abused in is a member excluding a ceremony they themselves proposed, whose key they would simply not have proposed. `AutoMigration(18, 19)`: two nullable columns, a shape Room migrates itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 18,
|
||||
version = 19,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -298,6 +298,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// clock, which is correct for them: nothing that predates subgroups ran
|
||||
// two ceremonies in one room.
|
||||
AutoMigration(from = 17, to = 18),
|
||||
// v19 adds two nullable DkgSession columns, both of which the ceremony's
|
||||
// room used to answer and no longer can: a subgroup's ChillDKG now runs
|
||||
// in the **parent's** Marmot room rather than in a NIP-17 room derived
|
||||
// from the child's admins. See docs/subgroups.md.
|
||||
//
|
||||
// `participantPublicKeys` is who the ceremony is with -- a subset of the
|
||||
// parent's members, so the room's roster is the wrong set -- and
|
||||
// `subject` is what the room it will make is to be called, which used to
|
||||
// be the ceremony room's own name. Both are read off the proposal, which
|
||||
// has carried both since before this move; what changed is where they
|
||||
// land. Rows written before this read back null and fall back to the
|
||||
// room, which is correct for them: a ceremony held in its own NIP-17 room
|
||||
// ran over exactly that room's members and was named after it.
|
||||
AutoMigration(from = 18, to = 19),
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -27,21 +27,44 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony **of its own**, newest first -- never one it is
|
||||
* only hosting on a subgroup's behalf.
|
||||
*
|
||||
* The distinction did not exist while every ceremony ran in a NIP-17 room of
|
||||
* its own. It does now: a subgroup's ChillDKG runs in the parent's Marmot
|
||||
* room, so that room holds a completed ceremony whose key is the *child's*,
|
||||
* and a caller asking "what key does this room sign with" must not be handed
|
||||
* it. That is `FrostSigningManager.completedKey`'s last fallback, which is
|
||||
* reached by every parent member who holds no key-state row -- which is
|
||||
* every member welcomed after the parent's own ceremony.
|
||||
*
|
||||
* A ceremony run to make a subgroup is never the room's own key, and
|
||||
* `parentChatRoomId` is the only thing that has to be read to know it. It is
|
||||
* an unverified claim, but not a dangerous one here: the direction it can be
|
||||
* abused in is a member *excluding* a ceremony from this answer, and a
|
||||
* ceremony they can exclude is one they proposed, whose key they would
|
||||
* simply not have proposed at all.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND parentChatRoomId IS NULL " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestOwnSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony *for one purpose*: the group's own when
|
||||
* [parentChatRoomId] is null, or the subgroup of that parent when it is not.
|
||||
*
|
||||
* A room can hold more than one. The ceremony room for a set of admins is
|
||||
* derived from those admins, so a subgroup whose admins are everybody lands
|
||||
* in the room the group's own ceremony was held in -- and that is a legitimate
|
||||
* subgroup rather than a mistake, because a subgroup is a logical division and
|
||||
* not a smaller membership.
|
||||
* A room routinely holds more than one, and since the move it is the ordinary
|
||||
* case rather than a corner: a parent's Marmot room hosts every subgroup
|
||||
* ceremony the group ever runs, alongside whatever it holds of its own.
|
||||
* `DkgSession.parentChatRoomId` is what tells those apart.
|
||||
*
|
||||
* `DkgSession.parentChatRoomId` is what tells them apart, and it is enough
|
||||
* because it is the only thing that differs: two ceremonies in one room are
|
||||
* either one group's and one subgroup's, or two subgroups' of different
|
||||
* parents. Two subgroups of the *same* parent over the *same* admins would
|
||||
* still collide, and that pair is one subgroup asked for twice.
|
||||
* It does **not** tell two subgroups of the same parent apart -- they share
|
||||
* both columns. Use [getLatestSubgroupSessionFor] where the question is which
|
||||
* subgroup; this answers "does this room hold a ceremony for that parent at
|
||||
* all", which is what a screen scoped to one lineage wants.
|
||||
*
|
||||
* Everything else about a ceremony is already keyed by session id -- the
|
||||
* messages, the approvals, the transcript -- so nothing below this needed to
|
||||
@@ -63,6 +86,40 @@ interface DkgSessionDao {
|
||||
)
|
||||
fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* The newest ceremony making a subgroup of [parentChatRoomId] over exactly
|
||||
* these admins, or null if this device knows of none.
|
||||
*
|
||||
* What "may I open another" means for a subgroup, now that the ceremony runs
|
||||
* in the parent's room. It used to be answered by the room: the ceremony room
|
||||
* was derived from the admins, so the same admins were the same room and
|
||||
* [getLatestSessionFor] folded a second ask into the first. The parent's room
|
||||
* is the same room for every subgroup it ever makes, so that scoping would
|
||||
* refuse the parent's *second* subgroup on the strength of its first.
|
||||
*
|
||||
* [participantPublicKeys] is the same sorted string
|
||||
* `DkgSession.formatParticipants` writes, which is why it is sorted: the
|
||||
* coordinator builds the set from a picker and every other device from
|
||||
* p-tags, and the two have to match as strings for this to find anything.
|
||||
*
|
||||
* Deliberately not scoped by stage -- the caller decides what a *finished*
|
||||
* ceremony means. `ChillDkgRitualManager.proposeRitual` folds into one that
|
||||
* is still running and opens a new one otherwise, because a subgroup that
|
||||
* has been made is not a reason to refuse making another over the same
|
||||
* people.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"parentChatRoomId = :parentChatRoomId AND " +
|
||||
"participantPublicKeys = :participantPublicKeys " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSubgroupSessionFor(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String,
|
||||
participantPublicKeys: String
|
||||
): DkgSession?
|
||||
|
||||
/**
|
||||
* Every ceremony this room holds, newest first.
|
||||
*
|
||||
|
||||
@@ -62,6 +62,43 @@ data class DkgSession(
|
||||
/** The `n`: how many members have to show up before params can be built. */
|
||||
val participantCount: Int,
|
||||
|
||||
/**
|
||||
* Everyone the ceremony is being run with, comma-separated hex, sorted --
|
||||
* or null on a row written before this column existed.
|
||||
*
|
||||
* The room used to answer this. It cannot any more: a subgroup's ceremony
|
||||
* runs in the **parent's** room, over a subset of the parent's members, so
|
||||
* the room's roster is the wrong set and reading it would put every parent
|
||||
* member in the child's admin list. The set is the proposal's p-tags plus
|
||||
* its sender, which is the same set every device derives `n` from, so what
|
||||
* is stored here cannot disagree with the ceremony it describes.
|
||||
*
|
||||
* Sorted so two devices that assembled the set in different orders store the
|
||||
* same string, which is what lets [DkgSessionDao.getLatestSubgroupSessionFor]
|
||||
* match on it.
|
||||
*
|
||||
* Null falls back to the room, which is right for every row that predates
|
||||
* this: a ceremony before subgroups moved ran in a room whose members were
|
||||
* exactly its participants.
|
||||
*/
|
||||
val participantPublicKeys: String? = null,
|
||||
|
||||
/**
|
||||
* What the room this ceremony's key derives is to be called, as the proposal
|
||||
* claimed -- or null for a ceremony that names nothing.
|
||||
*
|
||||
* Off the same `SubjectTag` the proposal has always carried. It used to land
|
||||
* on the ceremony's own NIP-17 room, which was the subgroup's name written
|
||||
* somewhere it could be read back; a ceremony held in the parent's room has
|
||||
* no room of its own to be named, and the parent's name is somebody else's.
|
||||
*
|
||||
* Unverified, like [parentChatRoomId] and for the same reason -- it is a
|
||||
* claim on a proposal. What checks it is the birth certificate, which the
|
||||
* parent's quorum signs over this very name, and which every admin sees
|
||||
* before agreeing to it.
|
||||
*/
|
||||
val subject: String? = null,
|
||||
|
||||
val stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS,
|
||||
|
||||
/** This device's ChillDKG host public key (33-byte compressed, hex). */
|
||||
@@ -168,4 +205,30 @@ data class DkgSession(
|
||||
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
|
||||
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Everyone this ceremony is being run with, or null on a row that predates
|
||||
* the column and so still means "the room's members".
|
||||
*
|
||||
* Callers that need an answer either way go through
|
||||
* `ChillDkgRitualManager.participantsOf`, which falls back to the room.
|
||||
*/
|
||||
fun participantPublicKeySet(): Set<HexKey>? = participantPublicKeys
|
||||
?.split(",")
|
||||
?.mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The stored form of a participant set: sorted, comma-separated hex.
|
||||
*
|
||||
* Sorted because the set is the same set whichever order it was
|
||||
* assembled in -- the coordinator builds it from a picker and every
|
||||
* other device from p-tags -- and two devices storing two strings for
|
||||
* one ceremony would make the column useless for matching on.
|
||||
*/
|
||||
fun formatParticipants(publicKeys: Set<HexKey>): String? =
|
||||
publicKeys.takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user