diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 59f14509..f9ecbcd6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -57,11 +57,11 @@ Create new chat Create profile Create project - Create the #admins group + Create the admin room Agree the group's signing key - The group is agreeing what the #admins room will sign with. It takes %1$s of %2$s members, and the request is in this chat. - The group has agreed what the #admins room will sign with. - The group could not agree what the #admins room will sign with. Ask again — a fresh request is the only safe way to retry. + The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat. + The group has agreed what the admin room will sign with. + The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it. Creating new chat. Currently no contacts. Please search and chat with a few people. 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 fcaffae4..525af65b 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 @@ -76,7 +76,7 @@ interface DkgSessionDao { /** * Every ceremony this device came out of holding a share, newest first. * - * Signing happens in the #admins room, whose id is derived from the key + * Signing happens in the admin room, whose id is derived from the key * rather than from the room the ceremony ran in, so the key is found by * matching that derivation rather than by a stored room id. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index 563d1269..dcdbb6f8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -188,7 +188,7 @@ abstract class MarmotOutboundDao( * Adds every member in [peers] to a group in a single MLS commit. * * The shape to use whenever the whole membership is known up front -- creating a - * room, or opening the #admins room after a key ceremony. One commit, one + * room, or opening the admin room after a key ceremony. One commit, one * Welcome carrying an EncryptedGroupSecrets per joiner, and crucially **no * intermediate epoch for anybody to miss**. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index c715be78..5f204355 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -886,7 +886,7 @@ abstract class NostrDao( } else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) { // A FROST signing message for one of our NIP-17 // groups, which is where a group makes its first - // signature: the statement of what its #admins room + // signature: the statement of what its admin room // signs with, agreed before that room is created. // See `GroupKeyStateManager.propose`. // diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index bf6cb6e6..eaa2fffb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -77,7 +77,7 @@ data class GroupSignedEvent( * * The exception is a group's first signature: its `GroupKeyStateEvent` is * signed in the NIP-17 room the ceremony ran in, and is authored by the - * #admins room that does not exist yet -- so this names where it happened + * admin room that does not exist yet -- so this names where it happened * and [publicKey] names who signed. [verifies] cannot pass on that row, for * the reason it gives. */ @@ -154,7 +154,7 @@ data class GroupSignedEvent( * of the derivation rather than of this check. * * And a group's first signature is made in the NIP-17 room its ceremony ran - * in, about the #admins room it is about to create, so [chatRoomId] is where + * in, about the admin room it is about to create, so [chatRoomId] is where * it happened and [publicKey] is who signed. Check that one with * `GroupKeyStateEvent.isSignedByGroup`, which asks the question this row * cannot: whether the *named* room signed it. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 77cb8324..dd4b69b8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -51,7 +51,7 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * ### Two transports, because a group signs before it has a room * - * Almost every signature is made in the group's #admins room, and there a + * Almost every signature is made in the group's admin room, and there a * signing message is an ordinary Marmot inner event needing no addressing of its * own: the room already exists, its membership is exactly the share holders, and * its id is derived from the key. One encrypted group event reaches everyone. @@ -73,7 +73,7 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * they expect is the id of the room they found it in. * * A session in a NIP-17 room signs as the room the ceremony's key derives -- - * the #admins room that does not exist yet -- which is the same rule read + * the admin room that does not exist yet -- which is the same rule read * forwards: what a group signs as is the room the signature belongs to. * * The path comes from the room, never from a proposal -- [signingPath] -- because @@ -1294,7 +1294,7 @@ object FrostSigningManager { /** * The key a room signs with, or null when it has none. * - * Signing usually runs in the #admins room, which is not where the ceremony + * Signing usually runs in the admin room, which is not where the ceremony * ran. A ceremony needs a NIP-17 group -- every member an equal admin, no * MLS tree to be outside of -- while a group event needs an MLS one, so the * two cannot be the same room. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index d95be257..780d80e3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -45,7 +45,7 @@ import press.mantra.compose.nostr.subgroup.SubgroupParentage * coordinator --[ 30320 proposal over a 30326 ]-> the same NIP-17 room * ...a quorum signs, on gift wraps... * every device holds the signed 30326, naming a room nobody has made yet - * coordinator creates the #admins room --> adopt() files the state as it appears + * coordinator creates the admin room --> adopt() files the state as it appears * ``` * * It used to be the new room's first application message, which meant the group @@ -75,7 +75,7 @@ object GroupKeyStateManager { * Asks the group to say what the room it is about to make signs with. * * Called once, by the ceremony's coordinator, in the NIP-17 room the - * ceremony ran in -- before the #admins room exists. [localChatRoom] is + * ceremony ran in -- before the admin room exists. [localChatRoom] is * therefore where the session *runs*, and is not usually the room the state * is *about*: that one is derived here, from [key] at [path], because the * room's id and the key it signs as are the same value. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt index c450c0f9..c25a8c11 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt @@ -24,10 +24,10 @@ import press.mantra.compose.repository.ChatRepository * Creating a Marmot room at an id the group's key derives, once, for every flow * that needs one. * - * Two do: the `#admins` room a group opens after its own ceremony, and a - * subgroup, which is the same act with four different values. This was 120 lines - * inside `DkgRitualViewModel` and is here unchanged in behaviour, because the - * rules it already gets right are not rules worth deriving twice. + * Two do: the admin room a group opens after its own ceremony, and a subgroup, + * which is the same act with four different values. This was 120 lines inside + * `DkgRitualViewModel` and is here unchanged in behaviour, because the rules it + * already gets right are not rules worth deriving twice. * * ### The three that are not obvious * @@ -96,6 +96,11 @@ object MarmotGroupCreation { * [parentChatRoomId] is written onto the room when this is a subgroup. It is * the verified value off the child's own key state -- never a claim off a * proposal. + * + * [name] is put under [MarmotGroupName] here rather than trusted from the + * caller. Every Marmot room on a device is a `#name`, and this is the funnel + * both flows that make one already go through, so it is the one place the + * convention cannot be forgotten. */ suspend fun create( database: MantraDatabase, @@ -112,6 +117,8 @@ object MarmotGroupCreation { ): Outcome { chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) } + val groupName = MarmotGroupName.of(name) + val peers = adminPublicKeys.filterNot { it == userPublicKey } val keyPackages = keyPackagesFor(chatRepository, peers, keyPackageLookupTimeout) @@ -131,7 +138,7 @@ object MarmotGroupCreation { // hardcodes a single admin. val metadata = MarmotGroupData( nostrGroupId = groupId, - name = name, + name = groupName, description = description, adminPubkeys = adminPublicKeys.toList(), relays = Relays.DefaultDMRelayList.map { it.url } @@ -151,7 +158,7 @@ object MarmotGroupCreation { chatRoomId = groupId, activeUserPublicKey = userPublicKey, relayHint = null, - defaultSubject = name, + defaultSubject = groupName, description = description, mlsGroupState = group.saveState().encodeTls().toHex() ) ?: return Outcome.Failed("Couldn't create the group. Please try again.") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt new file mode 100644 index 00000000..10ab958a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt @@ -0,0 +1,46 @@ +package press.mantra.compose.managers + +/** + * What a Marmot room is called: a `#`, then the name. + * + * A device's room list holds two unrelated kinds of room. A NIP-17 room is a + * conversation between the people in it; a Marmot room is a *group* -- it has an + * id its key derives, a membership baked into an MLS tree, admins who can act for + * it, and a signature anyone can check. Nothing on a row says which one is which, + * and the two behave differently enough that guessing is a mistake. The `#` says + * it, in the one place a user always reads. + * + * This replaces naming a group's Marmot room `"Ekklesia (#admins)"`. That form + * said the same thing in more words and only for the *admin* room -- a subgroup + * got no marker at all, so half the Marmot rooms on a device were unmarked, and + * "Ekklesia (#admins)" sorted nowhere near "Ekklesia". `#Ekklesia` marks both + * kinds, and marks them at the front where a truncated row still shows it. + * + * Applied where a Marmot room is minted rather than where it is drawn, because + * the name is baked into the epoch-0 `MarmotGroupData` every member is welcomed + * with: a name that only existed at the point of display would be a name this + * device alone could see. + */ +object MarmotGroupName { + /** Shown in a name field so the user reads the name they are about to get. */ + const val PREFIX = "#" + + /** + * [name] under the convention, and [name] again if it is already there. + * + * Idempotent on purpose. A subgroup's name is normalised twice from the same + * bare subject and by two unrelated callers -- once into the birth + * certificate the parent's quorum signs, once onto the room itself -- and + * those two have to be the same string, or the room is not called what its + * parent certified. + * + * A blank name is handed back untouched rather than turned into a bare `#`. + * No caller passes one -- each has a fallback for the room whose subject is + * missing -- and inventing a name here would hide that if one ever did. + */ + fun of(name: String): String { + val bare = name.trim().trimStart('#').trim() + + return if (bare.isEmpty()) name.trim() else PREFIX + bare + } +} 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 f5ed5367..c176d25a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -218,6 +218,12 @@ object SubgroupManager { require(name.isNotBlank()) { "A subgroup has to be called something" } + // The certificate is a signature over the name, so it has to be the name + // the room ends up with. `MarmotGroupCreation` normalises again on the + // way in and `MarmotGroupName.of` is idempotent, so the two agree by + // construction rather than by both callers remembering. + val subgroupName = MarmotGroupName.of(name) + // One certificate per child. A second is a `d`-tag replacement of the // first rather than a second subgroup, and spending a quorum's attention // to restate something they have already signed is worse than doing @@ -240,7 +246,7 @@ object SubgroupManager { parentChatRoomId = parentChatRoomId, thresholdPublicKey = thresholdPublicKey, adminPublicKeys = adminPublicKeys, - name = name, + name = subgroupName, path = path ), content = subgroupChatRoomId diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index 0118918e..e0c1a659 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -16,7 +16,7 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag * implied by the 3xxxx range never apply. Almost always that is a Marmot group * event -- MLS-encrypted to the group and then wrapped again under the group's * exporter secret. The exception is a group's first session, which agrees what - * its #admins room will sign with *before* that room exists and so runs in the + * its admin room will sign with *before* that room exists and so runs in the * NIP-17 room its ceremony ran in, on gift wraps. See * `GroupKeyStateManager.propose`. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 042a1d11..de8442ad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -165,7 +165,7 @@ interface ChatRepository { * Creates a Marmot room at an id the group's key derives, with * [adminPublicKeys] as its admins, and welcomes them into it. * - * Two flows want exactly this: the `#admins` room a group opens after its + * Two flows want exactly this: the admin room a group opens after its * ceremony, and a subgroup. See `MarmotGroupCreation`, which holds the rules * -- every key package before anything exists, the admins baked into epoch 0, * and the key state filed before the members are added. 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 991436f2..e60ec7f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -93,7 +93,7 @@ interface DkgRepository { ) /** - * Asks the group to sign a statement of which ceremony's key its #admins + * Asks the group to sign a statement of which ceremony's key its admin * room will sign with, before that room is created. * * Called once by the ceremony's coordinator, in the NIP-17 room the ceremony @@ -120,7 +120,7 @@ interface DkgRepository { fun observeSigningSessions(chatRoomId: String): Flow> /** - * The key state the group has signed for the #admins room this room's + * The key state the group has signed for the admin room this room's * ceremony will make, whether or not that room exists yet. * * The gate on creating it. Null means the group has not finished agreeing diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 14ee441f..75c07645 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -80,7 +80,7 @@ import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.composable.widgets.Decorative import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.create_the_admins_group +import mantra.composeapp.generated.resources.create_the_admin_room import androidx.compose.material.icons.filled.AccountTree import mantra.composeapp.generated.resources.create_the_subgroup import mantra.composeapp.generated.resources.ask_the_parent_group_to_certify @@ -100,9 +100,9 @@ import mantra.composeapp.generated.resources.start_key_ceremony import mantra.composeapp.generated.resources.the_ceremony_was_abandoned import mantra.composeapp.generated.resources.the_group_can_hold_one_key_together_split_so import mantra.composeapp.generated.resources.the_group_has_a_shared_key -import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admins_room -import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admins -import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admins_room +import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admin_room +import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admin +import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admin_room import mantra.composeapp.generated.resources.agree_the_groups_signing_key import mantra.composeapp.generated.resources.before_the_room_exists_the_group_signs import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs @@ -514,7 +514,7 @@ private fun RitualProgress( ) // What the group has to settle before there is a room: which - // ceremony's key the #admins room signs with. Signed first and + // ceremony's key the admin room signs with. Signed first and // filed as the room is created, so the room's founding fact is // never something it has to go and ask about afterwards. // @@ -595,20 +595,20 @@ private fun RitualProgress( ) { Icon(Icons.Default.CheckCircle, contentDescription = Decorative) Text( - text = stringResource(Res.string.the_group_has_agreed_what_the_admins_room), + text = stringResource(Res.string.the_group_has_agreed_what_the_admin_room), style = MaterialTheme.typography.labelMedium ) } keyStateFailed -> Text( - text = stringResource(Res.string.the_group_could_not_agree_what_the_admins), + text = stringResource(Res.string.the_group_could_not_agree_what_the_admin), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.error ) keyStateSession != null -> Text( text = stringResource( - Res.string.the_group_is_agreeing_what_the_admins_room, + Res.string.the_group_is_agreeing_what_the_admin_room, keyStateSession.threshold, keyStateSession.participantCount ), @@ -657,7 +657,7 @@ private fun RitualProgress( text = if (parentChatRoomId != null) { stringResource(Res.string.create_the_subgroup) } else { - stringResource(Res.string.create_the_admins_group) + stringResource(Res.string.create_the_admin_room) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 36181177..00efaa4f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -421,7 +421,7 @@ private fun FrostSigningScreenPreview() { chatRoom = ChatRoom( id = "chatRoomId", userPublicKey = "", - subject = "Group (#admins)", + subject = "#Ekklesia", description = null, initialGiftWrapPayloadId = "sdfaer", mlsGroupState = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 36cc6159..0ad4346f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -428,7 +428,7 @@ private fun ProposalListScreenPreview() { chatRoom = ChatRoom( id = "chatRoomId", userPublicKey = "u".repeat(64), - subject = "Group (#admins)", + subject = "#Ekklesia", description = null, initialGiftWrapPayloadId = "sdfaer", mlsGroupState = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index 28932cde..c2baba8c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -51,6 +51,7 @@ import mantra.composeapp.generated.resources.what_is_the_subgroup_called import mantra.composeapp.generated.resources.who_runs_the_subgroup import mantra.composeapp.generated.resources.you_coordinate_this_subgroup import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.navigation.routes.Route @@ -151,6 +152,12 @@ fun SelectSubgroupAdminsScreen( onValueChange = viewModel::setName, modifier = Modifier.fillMaxWidth(), singleLine = true, + // Every Marmot room is a `#name`, and a subgroup is + // one. Shown in the field rather than added on + // confirm alone, so the name the coordinator reads + // here is the name the parent's admins will be asked + // to approve. + prefix = { Text(MarmotGroupName.PREFIX) }, label = { Text(stringResource(Res.string.what_is_the_subgroup_called)) } ) } 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 f4ceb058..f452df8c 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 @@ -238,7 +238,7 @@ class DkgRitualViewModel( } /** - * Follows the group agreeing what its #admins room will sign with. + * Follows the group agreeing what its admin room will sign with. * * Nothing here watches a [GroupKeyState] row, because there is no room yet * for one to belong to -- being able to follow this before the room exists @@ -348,7 +348,7 @@ class DkgRitualViewModel( } /** - * Asks the group to sign what its #admins room will sign with. + * Asks the group to sign what its admin room will sign with. * * The step before the room exists, and the reason it can be created knowing * its own key state rather than being told afterwards. Offered to the @@ -578,14 +578,14 @@ class DkgRitualViewModel( // A subgroup is called what the coordinator called it; an admin room is // called after the group it administers, because it has no name of its - // own to be given. + // own to be given. Either way the bare name goes down and + // `MarmotGroupCreation` puts it under `MarmotGroupName`, so the NIP-17 + // room the ceremony ran in stays `Ekklesia` and the Marmot room it stands + // up is `#Ekklesia`. val subgroupParent = loaded.parentChatRoomId - val name = if (subgroupParent != null) { - loaded.localChatRoom.chatRoom.subject ?: "Subgroup" - } else { - "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" - } + val name = loaded.localChatRoom.chatRoom.subject + ?: if (subgroupParent != null) "Subgroup" else "Group" isActionPending.value = true dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList()) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index fccb41f0..2d574794 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository @@ -222,6 +223,12 @@ class SelectChatRoomTypeViewModel( ) { val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... + // Every Marmot room is a `#name`. This one does not go through + // `MarmotGroupCreation` -- its id is random rather than derived, so it has + // no key state to adopt and no admin set to bake in -- which is why the + // convention is applied here as well as there. + val groupName = MarmotGroupName.of(name) + // Stamp initial metadata via the shared factory so UI + CLI stay // byte-identical. Bake the MarmotGroupData extension into the // epoch-0 GroupContext directly (see `MarmotManager.createGroup`) @@ -232,7 +239,7 @@ class SelectChatRoomTypeViewModel( nostrGroupId = gid, creatorPubKey = keyPair.pubKey.toHexKey(), outboxRelays = Relays.DefaultDMRelayList.map { it.url }, - name = name, + name = groupName, description = description.orEmpty() ) @@ -255,7 +262,7 @@ class SelectChatRoomTypeViewModel( chatRoomId = gid, activeUserPublicKey = keyPair.pubKey.toHexKey(), relayHint = null, - defaultSubject = name, + defaultSubject = groupName, description = description, mlsGroupState = group.saveState().encodeTls().toHex() ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index df7fc00d..183578e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.withContext import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute @@ -66,6 +67,15 @@ class SelectSubgroupAdminsViewModel( /** Members ticked, in the order they were ticked. The coordinator is never here. */ val selectedPublicKeys = mutableStateListOf() + /** + * What the coordinator typed, bare. + * + * The `#` is drawn beside the field rather than pushed into this state: + * normalising on every keystroke would move the caret out from under somebody + * halfway through a word. It is added by `MarmotGroupName.of` where the + * subgroup's name is actually used -- in the certificate the parent signs and + * on the room itself -- which is idempotent, so the two agree. + */ val name: MutableState = mutableStateOf("") val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) @@ -213,11 +223,22 @@ class SelectSubgroupAdminsViewModel( return@launch } + // The ceremony room keeps the bare name. It is a NIP-17 room -- where + // the subgroup is made, not the subgroup -- and the `#` is what tells + // those two rows apart afterwards, so putting it on both would spend + // the mark to say nothing. + // + // The subgroup gets it twice over, and never from here: + // `proposeBirthCertificate` normalises the name the parent signs and + // `MarmotGroupCreation` normalises the name the room carries, both + // reading this subject. `MarmotGroupName.of` is idempotent, so those + // are the same string -- which they have to be, or the room is not + // called what its parent certified. val ceremonyRoom = chatRepository.createNip17ChatRoom( userPublicKey = activeUserPublicKey, participantPublicKeys = selectedPublicKeys.toList(), - subject = name.value, - description = "Making ${name.value} a subgroup of " + + subject = name.value.trim(), + description = "Making ${MarmotGroupName.of(name.value)} a subgroup of " + (loaded.parentRoom.chatRoom.subject ?: "this group") + "." ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt index e79b867d..52be6cb9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt @@ -37,7 +37,7 @@ sealed interface DkgRitualUIState { val pendingApproval: DkgApprovalStep? = null, /** * Members whose key package the coordinator could not find, and so who stop - * the #admins room being created at all. + * the admin room being created at all. * * Names rather than keys, because the answer to this is to go and ask that * person to open the app. Empty when nothing is blocked, which is also the @@ -45,7 +45,7 @@ sealed interface DkgRitualUIState { */ val adminGroupBlockedOn: List = emptyList(), /** - * The session in which the group is agreeing what its #admins room will + * The session in which the group is agreeing what its admin room will * sign with, once somebody has opened one. * * A ceremony's room holds exactly one of these and nothing else, because diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt new file mode 100644 index 00000000..ad9bde32 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.managers + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The convention, checked at the one function that applies it. + * + * Idempotence is the property worth a test. The name a subgroup ends up with + * passes through here four times -- typed, carried on the ceremony room, signed + * into the birth certificate, normalised again at creation -- and a rule that + * added a `#` each time would leave the room called something its parent never + * certified. + */ +class MarmotGroupNameTest { + @Test + fun `a plain name gets the prefix`() { + assertEquals("#Ekklesia", MarmotGroupName.of("Ekklesia")) + } + + @Test + fun `a name that already has the prefix keeps exactly one`() { + assertEquals("#Ekklesia", MarmotGroupName.of("#Ekklesia")) + assertEquals("#Ekklesia", MarmotGroupName.of("###Ekklesia")) + } + + @Test + fun `applying it twice says the same thing as applying it once`() { + val once = MarmotGroupName.of("Translators") + + assertEquals(once, MarmotGroupName.of(once)) + } + + @Test + fun `surrounding space is not part of the name`() { + assertEquals("#Ekklesia", MarmotGroupName.of(" Ekklesia ")) + assertEquals("#Ekklesia", MarmotGroupName.of("# Ekklesia")) + } + + @Test + fun `space inside the name is`() { + assertEquals("#Book club", MarmotGroupName.of("Book club")) + } + + @Test + fun `a blank name is left alone rather than turned into a bare hash`() { + assertEquals("", MarmotGroupName.of(" ")) + assertEquals("#", MarmotGroupName.of("#")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt index 33599999..d8f8bd19 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -194,7 +194,7 @@ class FrostSigningSessionDaoJvmTest { } /** - * Signing happens in the #admins room, and a device can be in more than one. A session from + * Signing happens in the admin room, and a device can be in more than one. A session from * another room appearing here would have a signer answering a request its group never made. */ @Test diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt index f58bf006..bb59a688 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt @@ -161,7 +161,7 @@ class GroupSignedEventDaoJvmTest { ChatRoom( id = chatRoomId, userPublicKey = member, - subject = "#admins", + subject = "#Ekklesia", description = SharedKeyDerivation.describe("Admins"), mlsGroupState = null, ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt index a70f1ca2..31c3687a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt @@ -98,7 +98,7 @@ class ChapterBatchProposalJvmTest { val chatRoom = ChatRoom( id = roomId, userPublicKey = proposer, - subject = "#admins", + subject = "#Ekklesia", description = SharedKeyDerivation.describe("Admins of the group."), mlsGroupState = null ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index 951be1cc..6500d8c9 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -191,7 +191,7 @@ class ChronicleApplyJvmTest { ChatRoom( id = chatRoomId, userPublicKey = user, - subject = "#admins", + subject = "#Ekklesia", description = null, mlsGroupState = null, ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt index 2f65958d..fe713e8d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt @@ -201,7 +201,7 @@ class ChronicleAssemblyJvmTest { ChatRoom( id = chatRoomId, userPublicKey = newMember, - subject = "#admins", + subject = "#Ekklesia", description = null, mlsGroupState = null, ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt index 071aea08..3461fcb8 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/MarmotGroupCreationJvmTest.kt @@ -63,7 +63,7 @@ class MarmotGroupCreationJvmTest { database = db, chatRepository = repository, groupId = groupId, - name = "Ekklesia (#admins)", + name = "#Ekklesia", purpose = "Admins of Ekklesia.", // A group of one, so nothing has to be invited and no key package has // to be found. The flag is the whole of what is being tested. @@ -94,7 +94,7 @@ class MarmotGroupCreationJvmTest { database = db, chatRepository = repository, groupId = groupId, - name = "Ekklesia (#admins)", + name = "#Ekklesia", purpose = "Admins of Ekklesia.", adminPublicKeys = setOf(user), userPublicKey = user, @@ -116,6 +116,35 @@ class MarmotGroupCreationJvmTest { assertTrue(data.adminPubkeys.contains(user)) } + @Test + fun `the room is named under the convention whatever the caller passed`(): Unit = runBlocking { + seedProfile(user) + + MarmotGroupCreation.create( + database = db, + chatRepository = repository, + groupId = groupId, + // A bare name, as a caller reading a room's subject straight off the + // database hands it over. The convention is this function's to apply: + // it is where both flows that make a Marmot room meet, and the name + // goes into the epoch-0 group context every member is welcomed with, + // so a caller that forgot would mark the room on no device at all. + name = "Ekklesia", + purpose = "Admins of Ekklesia.", + adminPublicKeys = setOf(user), + userPublicKey = user, + keyPair = keyPair, + ) + + val room = assertNotNull(db.chatRoomDao().findChatRoomById(groupId)) + val data = assertNotNull(room.chatRoom.toMlsGroup()?.currentMarmotData()) + + // Both, and the same string in each: the row is what this device draws in + // its room list, and the group data is what every other member reads. + assertEquals("#Ekklesia", room.chatRoom.subject) + assertEquals("#Ekklesia", data.name) + } + private suspend fun seedProfile(publicKey: String) { val nostrEventId = publicKey.take(63) + "f" db.nostrEventDao().upsert( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 79c0f283..05c24f91 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -237,7 +237,7 @@ class SignedGroupKeyStateTest { val chatRoom = ChatRoom( id = roomId, userPublicKey = publicKey, - subject = "#admins", + subject = "#Ekklesia", description = description, mlsGroupState = mlsGroupState ) @@ -601,7 +601,7 @@ class SignedGroupKeyStateTest { */ private val ceremonyRoomId = "ceremonyroom".padEnd(64, '0') - /** A member's device as it is *before* the #admins room exists. */ + /** A member's device as it is *before* the admin room exists. */ private suspend fun ceremonyDevice(publicKey: HexKey, signerIndex: Int): Device = device( publicKey = publicKey, @@ -614,13 +614,13 @@ class SignedGroupKeyStateTest { mlsGroupState = null ) - /** Brings the #admins room into being on a device, as creating or joining it would. */ + /** Brings the admin room into being on a device, as creating or joining it would. */ private suspend fun createAdminRoom(device: Device) { device.db.chatRoomDao().upsert( ChatRoom( id = adminRoomId, userPublicKey = device.publicKey, - subject = "#admins", + subject = "#Ekklesia", description = adminRoomDescription, mlsGroupState = "00" ) @@ -1391,7 +1391,7 @@ class SignedGroupKeyStateTest { // than anything walked to. There is no room key to sign as, so it signs // as the group's -- which is what it did before any of this. // - // A NIP-17 room in the same shape signs as the #admins room the ceremony + // A NIP-17 room in the same shape signs as the admin room the ceremony // will make, which is the case below. The two are told apart by whether // the room has MLS state, and by nothing a proposer can reach. val undeerived = "d".repeat(64) @@ -1420,7 +1420,7 @@ class SignedGroupKeyStateTest { @Test fun `a NIP-17 room that did not host the ceremony cannot sign at all`() = runBlocking { // The other half of the rule that lets a ceremony's room sign as the - // room it is about to make. That room signs as the #admins room *its + // room it is about to make. That room signs as the admin room *its // own* ceremony derives; a NIP-17 room this member merely happens to be // in reaches no key and no path, so it never gets as far as choosing an // identity to sign under. 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 c7f21435..e1400675 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -31,6 +31,7 @@ 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.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.GroupSignedEvent @@ -39,6 +40,7 @@ import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent /** @@ -428,6 +430,39 @@ class SubgroupManagerJvmTest { } } + @Test + fun `the name the parent is asked to sign is the name the room will carry`(): Unit = + runBlocking { + // A parent that can actually sign: the proposal has to get as far as + // writing the event whose tags are what this is about. + val parentRoom = parentWith(listOf(alice, bob)) + + val session = SubgroupManager.proposeBirthCertificate( + database = db, + parentRoom = parentRoom, + userPublicKey = user, + key = completedChildCeremony(), + adminPublicKeys = listOf(user, alice, bob), + // Bare, as it comes off the ceremony room's subject -- that room + // is NIP-17 and keeps the name unmarked. + name = "Translation team", + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).firstOrNull()) + .unsignedEventJson + ) + + // A quorum is being asked to sign a name, and `MarmotGroupCreation` + // will name the room by the same rule from the same subject. If only + // one of the two applied it, the parent would certify "Translation + // team" and the room would be called "#Translation team". + assertEquals( + MarmotGroupName.of("Translation team"), + SubgroupBirthCertificateEvent.parseName(proposed.tags) + ) + } + @Test fun `an unnamed subgroup cannot be certified`(): Unit = runBlocking { seedRoom(parentRoomId) @@ -530,7 +565,7 @@ class SubgroupManagerJvmTest { /** * A parent this device could actually make a subgroup of. * - * Built as what a real parent *is* -- the `#admins` room derived from the + * Built as what a real parent *is* -- the admin room derived from the * group's own key -- rather than as a NIP-17 room with MLS state bolted on. * That is not fussiness: `ChatRoom.deriveChatRoomId` returns a 33-byte * compressed key (66 hex) and `MarmotGroupData.nostrGroupId` takes 32 bytes, @@ -607,6 +642,21 @@ class SubgroupManagerJvmTest { secretShare = "cc".repeat(32), ) ) + + // A ceremony's signer ids come from its host keys, sorted -- there is + // no stored signer id to seed. Without these the parent holds a key it + // cannot find itself in, and `proposeSigningBatch` refuses before it + // writes anything. + (members + user).forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = "parent-ceremony", + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = "0${index + 1}".repeat(33) + ) + ) + } } return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId)) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt index 5edda78e..90f6f4cc 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt @@ -105,7 +105,7 @@ class TranslationBatchProposalJvmTest { val chatRoom = ChatRoom( id = roomId, userPublicKey = proposer, - subject = "#admins", + subject = "#Ekklesia", description = SharedKeyDerivation.describe("Admins of the group."), mlsGroupState = null ) diff --git a/docs/marmot-membership.md b/docs/marmot-membership.md index 46a783b3..24510262 100644 --- a/docs/marmot-membership.md +++ b/docs/marmot-membership.md @@ -106,7 +106,7 @@ with that peer's key package. Both callers that know their membership up front now use it — `SelectChatRoomTypeViewModel.inviteMembers` at room creation, and -`DkgRitualViewModel.inviteAdmins` for the `#admins` room. +`DkgRitualViewModel.inviteAdmins` for the admin room. ### Why this needed no schema change diff --git a/docs/member-chronicle.md b/docs/member-chronicle.md index c2ffcf8c..e2986b9d 100644 --- a/docs/member-chronicle.md +++ b/docs/member-chronicle.md @@ -79,7 +79,7 @@ if (!event.pubKey.equals(author, ignoreCase = true)) return false That derived value is the room's id. `GroupKeyState.isMatchedBy` enforces it, `FrostSigningManager.signingPath` resolves the path by it, and -`DkgRitualViewModel` creates the `#admins` room *at* it. So for any room with a +`DkgRitualViewModel` creates the admin room *at* it. So for any room with a shared key, `marmotGroupId(thresholdPublicKey, path) == chatRoomId`, and the check collapses to: diff --git a/docs/shared-key-ceremony.md b/docs/shared-key-ceremony.md index 919131bf..65657faa 100644 --- a/docs/shared-key-ceremony.md +++ b/docs/shared-key-ceremony.md @@ -134,15 +134,25 @@ protocol checks this, so `ChillDkgRitualOrderingTest` does. The group has a threshold public key; each device keeps its own share, restorable from that member's wallet backup and nobody else's. -From there the coordinator can create the `#admins` room — a Marmot group whose id -is derived from the shared key. See [shared-key-derivation.md](./shared-key-derivation.md) -for how, and [marmot-membership.md](./marmot-membership.md) for how members are -added to it. +From there the coordinator can create the group's admin room — a Marmot group +whose id is derived from the shared key. See +[shared-key-derivation.md](./shared-key-derivation.md) for how, and +[marmot-membership.md](./marmot-membership.md) for how members are added to it. + +It is named `#` and then the group's name — `#Ekklesia` for a group called +Ekklesia. Every Marmot room on a device carries that mark and no NIP-17 room does, +which is the only thing on a room list that says which of the two a row is. The +rule is one function, `MarmotGroupName.of`, applied where a room is minted rather +than where it is drawn: the name is baked into the epoch-0 `MarmotGroupData` every +member is welcomed with, so a name added at display time would be a name this +device alone could see. It replaces `Ekklesia (#admins)`, which marked only the +admin room, left every subgroup unmarked, and sorted nowhere near the group it +belonged to. ## Known gaps - No ceremony has been run on a physical device. -- A member invited to the `#admins` room whose Welcome never goes out is +- A member invited to the admin room whose Welcome never goes out is indistinguishable, from the coordinator's side, from one who joined. - The ritual's request chat lines are `3n + 4` per ceremony — 19 lines for five members. Compact, but they dominate a transcript while a ceremony runs. diff --git a/docs/shared-key-derivation.md b/docs/shared-key-derivation.md index 00e1d318..2332f2f0 100644 --- a/docs/shared-key-derivation.md +++ b/docs/shared-key-derivation.md @@ -110,7 +110,7 @@ leak reaches the others. `derive` and `marmotGroupId` both take `path: List`, defaulting to `MARMOT_ADMIN_GROUP_PATH` (`m/9420/0/0`). Any depth works. -`9420` is arbitrary and has to stay put: the derived key *is* the `#admins` room's +`9420` is arbitrary and has to stay put: the derived key *is* the admin room's id, so changing the path orphans every room already created — members would derive a different id and stop finding the room at all. diff --git a/docs/subgroups.md b/docs/subgroups.md index 5120e2a3..15565c75 100644 --- a/docs/subgroups.md +++ b/docs/subgroups.md @@ -698,12 +698,20 @@ point of a subgroup is that it can be run by people the parent does not let run the parent. **A name field at the top**, because nothing else in the flow can supply one. -`createAdminGroup` synthesises `"${parent.subject} (#admins)"` and gets away with -it — a group has exactly one admin room and the name states a relationship rather -than a choice — but a group can have many subgroups and "Ekklesia (#subgroup)" -names none of them. The name is required, travels on the route to Phase 6's -`MarmotGroupData`, and is copied into the birth certificate so the parent's admins -approve something legible. +`createAdminGroup` gets away with naming the admin room after the group it +administers — a group has exactly one — but a group can have many subgroups, and +naming them all after the parent names none of them. The name is required, travels +on the route to Phase 6's `MarmotGroupData`, and is copied into the birth +certificate so the parent's admins approve something legible. + +`MarmotGroupName.of` puts the `#` on it. The field shows the prefix beside what is +typed rather than in it, so the caret does not move under somebody mid-word, and +the ceremony room keeps the bare name — it is a NIP-17 room, and the mark is what +tells it apart from the subgroup it stands up. The subgroup's own name is derived +from that subject twice, by two unrelated callers: `proposeBirthCertificate` +normalises the name the parent signs, `MarmotGroupCreation` normalises the name the +room carries. Those two have to be the same string or the subgroup is not called +what its parent certified, which is what the rule being idempotent buys. ### Key packages, checked here rather than discovered at step 4