From 1930d6aaefde5aa5f13ee889c1650c7c2ef42446 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 00:38:49 +0200 Subject: [PATCH] fix(subgroups): a subgroup may be the whole group "A subgroup cannot be the whole group. Leave at least one member out." That rule shipped in Phase 8 and it was wrong twice. **It refused something legitimate.** A subgroup is a logical division -- a group deciding that some of its work belongs to a differently-keyed room -- not a group carving out a smaller membership. Every member being in it is an ordinary case, and no guard here had any business deciding otherwise. **And it was a proxy, not a check.** The thing it stood in for is real: a ceremony room is derived from its admins, so a subgroup over everybody lands in the room the group's *own* ceremony was held in, and `proposeRitual` handing back that ceremony would quietly make the child the parent. But set size does not detect that. A parent whose membership has changed since its own ceremony derives a different room -- so the sizes can match with no collision, and differ with one. **Sharing the room was never the problem; sharing a ceremony was.** `DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to scope the lookup by it rather than to forbid the selection. `DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the three places that decide whether a ceremony already exists: `proposeRitual`'s one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the ritual screen follows. A room may now hold the group's own ceremony and a subgroup's at once. Nothing below that lookup had to learn about the second one. A ceremony's messages, approvals and transcript are already keyed by session id; only the question "what is this room's current ceremony" was ever room-scoped, and that question was always really "for what purpose". One collision survives and it is degenerate: the same parent, over the same admins, twice. Those two have nothing left to distinguish them -- which is another way of saying they are one subgroup asked for twice, and that is what the message now says. `a subgroup that is the whole group is refused` becomes `a subgroup may be the whole group`, and two new cases pin the scoping: the group's own ceremony sitting in the derived room does not block a subgroup there, and the same parent asking twice over the same admins still does while a different admin set is untouched. `docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table with a section saying why, rather than quietly deleting it -- the reasoning that led to it is the reasoning somebody would repeat. 397 common tests, 713 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/DkgSessionDao.kt | 36 ++++++ .../repository/DatabaseDkgRepository.kt | 16 ++- .../compose/managers/ChillDkgRitualManager.kt | 23 ++-- .../compose/managers/SubgroupManager.kt | 31 +++-- .../compose/repository/DkgRepository.kt | 30 ++++- .../ui/view/model/DkgRitualViewModel.kt | 6 +- .../managers/SubgroupManagerJvmTest.kt | 113 ++++++++++++------ docs/subgroups.md | 66 +++++++--- 8 files changed, 236 insertions(+), 85 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index c150ccb2..7ebe56e4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -27,6 +27,42 @@ 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 *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. + * + * `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. + * + * Everything else about a ceremony is already keyed by session id -- the + * messages, the approvals, the transcript -- so nothing below this needed to + * learn about the second one. + */ + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "(parentChatRoomId = :parentChatRoomId OR " + + "(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " + + "ORDER BY createdAt DESC LIMIT 1" + ) + suspend fun getLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): DkgSession? + + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "(parentChatRoomId = :parentChatRoomId OR " + + "(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " + + "ORDER BY createdAt DESC LIMIT 1" + ) + fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow + /** * Every ceremony this device came out of holding a share, newest first. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 89bac5fe..204015ad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -28,8 +28,11 @@ class DatabaseDkgRepository( ): DkgRepository { private val logger = Logger.withTag(TAG) - override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = - database.dkgSessionDao().observeLatestSessionForChatRoom(chatRoomId) + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = + database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId) override fun observeMessages(sessionId: String): Flow> = database.dkgSessionDao().observeMessages(sessionId) @@ -86,7 +89,10 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) - override fun observeSignedGroupKeyState(chatRoomId: String): Flow = + override fun observeSignedGroupKeyState( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> @@ -94,7 +100,7 @@ class DatabaseDkgRepository( // rather than once: the ceremony has no key until it finishes, // and this flow is running before it does. val adminRoomId = database.dkgSessionDao() - .getLatestSessionForChatRoom(chatRoomId) + .getLatestSessionFor(chatRoomId, parentChatRoomId) ?.thresholdPublicKey ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } @@ -149,7 +155,7 @@ class DatabaseDkgRepository( // rather than once: the ceremony has no key until it finishes, // and this flow is running before it does. val subgroupChatRoomId = database.dkgSessionDao() - .getLatestSessionForChatRoom(ceremonyChatRoomId) + .getLatestSessionFor(ceremonyChatRoomId, parentChatRoomId) ?.thresholdPublicKey ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } ?: return@map null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index f2acb856..ad575324 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -125,17 +125,24 @@ object ChillDkgRitualManager { threshold: Int, parentChatRoomId: HexKey? = null ): DkgSession { - // A room runs one ceremony at a time, and this is reachable twice now that - // a robust group opens one as it is created: the room id is derived from - // its members, so making the same group again lands back in the same room - // and asks again. A second proposal is a second participant set for every - // member to reconcile, and the key the first one produced would be left - // with nothing pointing at it. A failed ritual is not running, and is - // there to be replaced. + // A room runs one ceremony at a time *for one purpose*, and this is + // reachable twice now that a robust group opens one as it is created: the + // room id is derived from its members, so making the same group again + // lands back in the same room and asks again. A second proposal for the + // same purpose is a second participant set for every member to reconcile, + // and the key the first one produced would be left with nothing pointing + // at it. A failed ritual is not running, and is there to be replaced. + // + // Scoped by [parentChatRoomId] rather than by room alone. A subgroup's + // ceremony room is derived from its admins, so a subgroup whose admins are + // the whole group lands in the room the group's own ceremony was held in + // -- and that is a legitimate subgroup, since a subgroup is a logical + // division rather than a smaller membership. Refusing it on the room would + // hand back the *group's* key and quietly make the child the parent. // // Checked before the arguments are, because a running ritual makes the // requested threshold moot -- it settled that question when it opened. - database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id) + database.dkgSessionDao().getLatestSessionFor(localChatRoom.chatRoom.id, parentChatRoomId) ?.takeIf { it.stage != DkgRitualStage.FAILED } ?.let { running -> logger.i( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index d3664934..f5ed5367 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -157,21 +157,26 @@ object SubgroupManager { return "Everyone in a subgroup has to be in this group first." } - // A proper subset, and this is not fussiness. The ceremony room is derived - // from its members, so selecting the whole group derives the room this - // group's own ceremony was held in -- and `proposeRitual` would hand back - // that ceremony, making the "child" this very group. - if (admins.size == members.size) { - return "A subgroup cannot be the whole group. Leave at least one member out." - } - - // Any completed or running ceremony in the room these admins derive is the - // same trap one step removed: two subgroups with exactly the same admins - // would share a key and therefore a room id. + // A subgroup *may* be the whole group. It is a logical division -- a group + // deciding that some of its work belongs to a differently-keyed room -- + // rather than a smaller membership, so "everybody" is a normal answer and + // was never this function's business to refuse. + // + // What the old rule was standing in for is real and is checked below, + // precisely: the ceremony room is derived from its admins, so a subgroup + // over everybody lands in the room the group's own ceremony was held in. + // Sharing the room is fine; sharing a *ceremony* is not, and + // `DkgSession.parentChatRoomId` is what keeps them apart. + // + // Checking on set size was wrong twice over. It refused a legitimate + // subgroup, and it refused it on a proxy: a parent whose membership has + // changed since its own ceremony derives a different room, so the sizes + // could match with no collision at all, and could differ with one. val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey) - val running = database.dkgSessionDao().getLatestSessionForChatRoom(ceremonyRoomId) + val running = database.dkgSessionDao() + .getLatestSessionFor(ceremonyRoomId, parentChatRoomId) if (running != null && running.stage != DkgRitualStage.FAILED) { - return "These members already hold a shared key together. Change who is in the subgroup." + return "This group already has a subgroup run by exactly these members." } return null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index c2d15dac..9e5a9231 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -20,8 +20,19 @@ import kotlinx.coroutines.flow.flowOf * not publish on their behalf until they say so. */ interface DkgRepository { - /** The room's current ritual — abandoned attempts are superseded by the newest. */ - fun observeLatestSessionForChatRoom(chatRoomId: String): Flow + /** + * The room's current ritual for one purpose — the group's own when + * [parentChatRoomId] is null, or that parent's subgroup when it is not. + * Abandoned attempts are superseded by the newest. + * + * A room can hold two: a subgroup whose admins are the whole group derives + * the room the group's own ceremony ran in. See + * `DkgSessionDao.getLatestSessionFor`. + */ + fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String? = null, + ): Flow fun observeMessages(sessionId: String): Flow> @@ -92,7 +103,10 @@ interface DkgRepository { * changes is the moment there is a room to make -- and the state it is * about does not exist as a row until somebody makes one. */ - fun observeSignedGroupKeyState(chatRoomId: String): Flow + fun observeSignedGroupKeyState( + chatRoomId: String, + parentChatRoomId: String? = null, + ): Flow /** Files the state the group signed for a room that now exists. */ suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? @@ -140,7 +154,10 @@ interface DkgRepository { companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { - override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(null) override fun observeMessages(sessionId: String): Flow> = flowOf(emptyList()) @@ -173,7 +190,10 @@ interface DkgRepository { chatRoomId: String ): Flow> = flowOf(emptyList()) - override fun observeSignedGroupKeyState(chatRoomId: String): Flow = + override fun observeSignedGroupKeyState( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(null) override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 01fd6639..9d69a147 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -134,7 +134,8 @@ class DkgRitualViewModel( observeKeyState() - dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session -> + dkgRepository.observeLatestSessionForChatRoom(chatRoomId, parentChatRoomId) + .collect { session -> val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) ?: DkgRitualUIState.Loaded( localChatRoom = localChatRoom, @@ -212,7 +213,8 @@ class DkgRitualViewModel( } launch { - dkgRepository.observeSignedGroupKeyState(chatRoomId).collect { state -> + dkgRepository.observeSignedGroupKeyState(chatRoomId, parentChatRoomId) + .collect { state -> val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index d4b781c6..c7f21435 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -330,56 +330,60 @@ class SubgroupManagerJvmTest { } @Test - fun `a subgroup that is the whole group is refused`(): Unit = runBlocking { - // The trap this exists for: the ceremony room is derived from its members, - // so selecting everybody derives the room this group's own ceremony was - // held in -- and `proposeRitual` would hand that ceremony back, making the - // "child" this very group. + fun `a subgroup may be the whole group`(): Unit = runBlocking { + // A subgroup is a logical division -- a group deciding that some of its + // work belongs to a differently-keyed room -- not a smaller membership. + // "Everybody" is a normal answer. + // + // This used to be refused on set size, which was wrong twice: it turned + // down a legitimate subgroup, and it did so on a proxy for a collision + // that a parent whose membership has changed would not even have. val parent = parentWith(listOf(alice, bob)) - assertNotNull(refusal(parent, setOf(alice, bob))) + assertNull(refusal(parent, setOf(alice, bob))) } @Test - fun `an admin set that already holds a ceremony is refused`(): Unit = runBlocking { + fun `the group's own ceremony does not block a subgroup in the same room`(): Unit = + runBlocking { + // The collision the size rule was standing in for, made concrete. A + // subgroup over everybody derives the room the group's own ceremony was + // held in -- so the room is shared, and only `parentChatRoomId` keeps + // the two ceremonies apart. Without that scoping `proposeRitual` would + // hand back the *group's* key and quietly make the child the parent. + val parent = parentWith(listOf(alice, bob)) + val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user) + + seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user)) + db.dkgSessionDao().upsert(ownCeremony(ceremonyRoomId)) + + assertNull( + refusal(parent, setOf(alice, bob)), + "the group's own ceremony is not this subgroup's", + ) + } + + @Test + fun `the same parent cannot have two subgroups over the same admins`(): Unit = runBlocking { val parent = parentWith(listOf(alice, bob, carol)) assertNull(refusal(parent, setOf(alice, bob))) - // One admin set, one ceremony room, forever -- the id is a pure function - // of the members. A second subgroup with the same admins would come back - // with the first one's key and therefore the first one's room id. - // - // Room first, the way the picker stands one up: the ceremony room is a - // real NIP-17 room and `DkgSession.chatRoomId` is a foreign key onto it. - val ceremonyRoom = assertNotNull( - db.nostrNip17Dao().createNip17ChatRoom( - userPublicKey = user, - participantPublicKeys = listOf(alice, bob), - subject = "Translation team", - ) - ) - assertEquals( - SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), - ceremonyRoom.chatRoom.id, - "the derived ceremony room is the one createNip17ChatRoom lands on", - ) - + // The one collision that survives, and it is degenerate: same parent, + // same admins. The ceremony room is derived from the admins and the + // ceremonies in it are told apart by their parent, so this pair has + // nothing left to distinguish them -- which is another way of saying it + // is one subgroup asked for twice. + val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user) + seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user)) db.dkgSessionDao().upsert( - DkgSession( - id = "s1", - chatRoomId = ceremonyRoom.chatRoom.id, - coordinatorPublicKey = user, - userPublicKey = user, - threshold = 2, - participantCount = 3, - hostPublicKey = user, - round1Random = "aa".repeat(32), - round2AuxRandom = "bb".repeat(32), - ) + ownCeremony(ceremonyRoomId).copy(id = "s1", parentChatRoomId = parentRoomId) ) assertNotNull(refusal(parent, setOf(alice, bob))) + + // And a different admin set is a different room, so it is untouched. + assertNull(refusal(parent, setOf(alice, carol))) } @Test @@ -445,6 +449,41 @@ class SubgroupManagerJvmTest { // ---- fixtures ---------------------------------------------------------- + /** + * The NIP-17 room a ceremony runs in, stood up the way the picker does it -- + * room first, because `DkgSession.chatRoomId` is a foreign key onto it. + */ + private suspend fun seedCeremonyRoom(expectedId: String, members: List) { + members.forEach { seedProfile(it) } + + val room = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = members.filterNot { it == user }, + subject = "Translation team", + ) + ) + + assertEquals( + expectedId, + room.chatRoom.id, + "the derived ceremony room is the one createNip17ChatRoom lands on", + ) + } + + /** A ceremony held in [chatRoomId] for no subgroup: the group's own. */ + private fun ownCeremony(chatRoomId: String) = DkgSession( + id = "own-ceremony", + chatRoomId = chatRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = 2, + participantCount = 3, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + ) + /** A finished ceremony for the child, as far as these guards can tell. */ private fun completedChildCeremony() = DkgSession( id = "child-ceremony", diff --git a/docs/subgroups.md b/docs/subgroups.md index b27457ac..5120e2a3 100644 --- a/docs/subgroups.md +++ b/docs/subgroups.md @@ -398,11 +398,16 @@ comment: - Selecting an admin set that already holds a completed ceremony returns that ceremony (`proposeRitual` hands back anything not `FAILED`), which would make the "new" subgroup the old group under a new name — same key, same room id. -- Selecting *every* member of the parent is the pathological case of that: the - parent's own ceremony room is the room over all its members, so the child would - come out as the parent itself. +- Selecting *every* member of the parent lands in the parent's own ceremony room, + which is the room over all its members. -Refused in Phase 8, on both readings, before the button is offered. +The second is **not** refused, and the first is refused more narrowly than it +first shipped. A subgroup over everybody is legitimate -- a subgroup is a logical +division rather than a smaller membership -- and sharing a room was never the +problem; sharing a *ceremony* was. `DkgSession.parentChatRoomId` tells two +ceremonies in one room apart, and `DkgSessionDao.getLatestSessionFor` is scoped by +it, so what Phase 8 refuses is only the same parent asking twice over the same +admins. See [that phase](#phase-8--the-refusals). ### Why not the parent's Marmot room @@ -784,16 +789,43 @@ a room nobody can replace. | a threshold outside `ChatRoomType.quorumRange(adminCount)` | `t = 1` is a threshold key any one member signs with, and ChillDKG will happily generate one. `acceptProposal` already refuses such a proposal, so an unchecked one produces a ceremony every invitee silently drops | | an empty or blank name | Phase 6 has nothing to put in `MarmotGroupData.name`, and the parent's admins would be certifying an unnamed hash | | a selected member with no unconsumed key package | they cannot be welcomed into the room at step 4, and finding that out at step 4 wastes three ceremonies. Checked at the picker and again at confirm | -| the selection is not a proper subset of the parent's members | selecting everybody derives the parent's own ceremony room, and the "child" comes out as the parent | -| the ceremony room already holds a non-`FAILED` `DkgSession` | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id | +| ~~the selection is not a proper subset of the parent's members~~ | **withdrawn.** A subgroup is a logical division, not a smaller membership, so "everybody" is a normal answer. What this was standing in for is the row below, checked precisely rather than by set size | +| the ceremony room already holds a non-`FAILED` `DkgSession` **for this same parent** | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id. Scoped by `DkgSession.parentChatRoomId`, so a room may hold the group's own ceremony *and* a subgroup's | | this device holds no share of the parent's key | it cannot open the certificate session; `proposeSigningBatch` throws, and throwing at the button is not a UI | | this device is not an admin of the parent | a non-admin proposing the parent's signature is a proposal the parent's admins have to decline by hand | | the parent has no signed key state and no resolvable key | there is nothing for the certificate to be signed with | | a certificate already stands for this child | one certificate per child; a second is a `d`-tag replacement of the first, not a second subgroup | -The first and the third are the ones a user will actually meet, so both say what -to do: "pick at least two more people" and "a subgroup cannot be the whole group -— leave at least one member out". +The first is the one a user will actually meet, and it says what to do: "pick at +least two more people". + +### Why "a subgroup cannot be the whole group" was withdrawn + +It shipped, and it was wrong twice. + +It refused something legitimate. A subgroup is a group deciding that some of its +work belongs to a differently-keyed room, not a group carving out a smaller +membership — so every member being in it is an ordinary case, and no rule here had +any business deciding otherwise. + +And it was a proxy rather than a check. The thing it stood in for is real: a +ceremony room is derived from its admins, so a subgroup over everybody lands in +the room the group's *own* ceremony was held in, and `proposeRitual` handing back +that ceremony would make the child the parent. But set size does not detect that. +A parent whose membership has changed since its own ceremony derives a different +room, so the sizes can match with no collision and differ with one. + +**Sharing the room was never the problem; sharing a ceremony was.** +`DkgSession.parentChatRoomId` already told them apart, so the fix was to scope the +lookup by it — `DkgSessionDao.getLatestSessionFor(room, parent)` — rather than to +forbid the selection. A room may now hold two ceremonies, and everything below +that lookup already keyed off the session id, so nothing else had to learn about +the second one. + +One collision survives, and it is degenerate: the same parent, over the same +admins, twice. Those two have nothing left to distinguish them, which is another +way of saying they are one subgroup asked for twice, and that is what the message +now says. ## Phase 9 — the tests that prove it @@ -871,12 +903,16 @@ about whether the child's chronicle may carry an event its own key did not sign, which no chroniclable kind does today. That last one is the real work, and it is why this is a follow-up rather than a line in Phase 5. -**One admin set, one subgroup.** Phase 4's collision is refused, not solved. Two -subgroups with exactly the same admins need the NIP-17 ceremony room to be -distinguishable by something other than its members, and -`ChatRoom.deriveChatRoomId` is a pure aggregate of member keys that the inbound -path recomputes. Changing it is a change to how every NIP-17 room in the app is -addressed, and it is not worth making for this. +**One parent, one admin set, one subgroup.** Narrower than it first shipped: a +room may hold two ceremonies, told apart by `DkgSession.parentChatRoomId`, so a +group's own ceremony and a subgroup over the same people coexist. What is still +refused is the same parent asking twice over the same admins, which have nothing +left to distinguish them. + +Widening that further needs the NIP-17 ceremony room to be distinguishable by +something other than its members, and `ChatRoom.deriveChatRoomId` is a pure +aggregate of member keys that the inbound path recomputes. Changing it is a change +to how every NIP-17 room in the app is addressed. It is also the limitation that disappears on its own: move the ceremony into the parent's Marmot room, as [Phase 4](#why-not-the-parents-marmot-room) defers doing,