From 45cc80b538df4f217de28edc3cd19b41f5e7361a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 11:41:30 +0200 Subject: [PATCH] feat(marmot): put a # in front of every group's name, and retire (#admins) A device's room list holds two unrelated kinds of room and nothing on a row said which. A NIP-17 room is a conversation between the people in it. A Marmot room is a *group* -- an id its key derives, a membership baked into an MLS tree, admins who can act for it, a signature anyone holding the id can check -- and the two behave differently enough that guessing is a mistake. `"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half. Only the admin room got it; a subgroup got no marker at all, so as soon as a group had one child, half the Marmot rooms on the device were unmarked. It also sorted nowhere near the group it belonged to, and a truncated row drops a trailing suffix first -- so the marker was missing exactly where the list is crowded enough to need it. **The rule is `MarmotGroupName.of`, and it runs 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 `#` added at display time would be a name this device alone could see. `#Ekklesia` marks both kinds of group room, and marks them at the front. **Three mints, because there are three ways a Marmot room comes into existence.** `MarmotGroupCreation.create` is the funnel for two of them -- the admin room a group opens after its ceremony, and a subgroup -- and normalising there means neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s convenient room, has a random id rather than a derived one, so it has no key state to adopt and no admin set to bake in and does not pass through that funnel; it applies the rule itself. **Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice from the same bare ceremony-room subject, by two callers that never see each other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's quorum is asked to sign, and `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 -- and a certificate is a signature over the name, so a verifier comparing them would see a real mismatch. `of` being idempotent is what makes them agree by construction rather than by both sites being kept in step. **The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup is made, not the subgroup -- and prefixing it too produced two identically-named rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits beside `#Translators` (the group it stood up), which is the distinction the `#` exists to draw. Its subject is trimmed, so the bare name and the two normalised ones cannot differ by whitespace. **The `#` is drawn beside the name field, not pushed into its state.** `name` in `SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the convention, because normalising on every keystroke moves the caret out from under somebody halfway through a word. The coordinator still reads the name they are about to get. Four strings lose the old name -- "Create the #admins group" becomes "Create the admin room", and the three about what "the #admins room" will sign with now say "the admin room". Their keys are renamed with them, since the keys in this catalogue are derived from the text. Around twenty comments, two screen previews and seven test fixtures follow. Docs: the ceremony note states the convention and what it replaces, and the subgroups note's name-field section is rewritten -- it had been arguing from the `"${parent.subject} (#admins)"` synthesis that no longer exists. `docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured debugging log, and rewriting it would falsify a record. Three tests. `MarmotGroupNameTest` pins the rule, idempotence included. `MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both the room row this device draws and the group data every other member reads. `SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed event's tags back out of the signing session: the name the parent is asked to sign is the name `MarmotGroupCreation` will give the room. That last one needed the signable-parent fixture to seed host keys, since a ceremony's signer ids are derived from them rather than stored. **Rooms that already exist keep their names.** The name lives in the epoch-0 group context, so renaming one is an MLS commit every member has to process -- a different change from a naming convention, and not made here. 403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 8 +-- .../compose/database/dao/DkgSessionDao.kt | 2 +- .../compose/database/dao/MarmotOutboundDao.kt | 2 +- .../mantra/compose/database/dao/NostrDao.kt | 2 +- .../database/model/GroupSignedEvent.kt | 4 +- .../compose/managers/FrostSigningManager.kt | 6 +-- .../compose/managers/GroupKeyStateManager.kt | 4 +- .../compose/managers/MarmotGroupCreation.kt | 19 ++++--- .../compose/managers/MarmotGroupName.kt | 46 ++++++++++++++++ .../compose/managers/SubgroupManager.kt | 8 ++- .../compose/nostr/frost/FrostSigningEvents.kt | 2 +- .../compose/repository/ChatRepository.kt | 2 +- .../compose/repository/DkgRepository.kt | 4 +- .../compose/ui/composable/DkgRitualScreen.kt | 18 +++---- .../ui/composable/FrostSigningScreen.kt | 2 +- .../ui/composable/ProposalListScreen.kt | 2 +- .../composable/SelectSubgroupAdminsScreen.kt | 7 +++ .../ui/view/model/DkgRitualViewModel.kt | 16 +++--- .../view/model/SelectChatRoomTypeViewModel.kt | 11 +++- .../model/SelectSubgroupAdminsViewModel.kt | 25 ++++++++- .../compose/ui/view/state/DkgRitualUIState.kt | 4 +- .../compose/managers/MarmotGroupNameTest.kt | 50 ++++++++++++++++++ .../dao/FrostSigningSessionDaoJvmTest.kt | 2 +- .../dao/GroupSignedEventDaoJvmTest.kt | 2 +- .../managers/ChapterBatchProposalJvmTest.kt | 2 +- .../compose/managers/ChronicleApplyJvmTest.kt | 2 +- .../managers/ChronicleAssemblyJvmTest.kt | 2 +- .../managers/MarmotGroupCreationJvmTest.kt | 33 +++++++++++- .../managers/SignedGroupKeyStateTest.kt | 12 ++--- .../managers/SubgroupManagerJvmTest.kt | 52 ++++++++++++++++++- .../TranslationBatchProposalJvmTest.kt | 2 +- docs/marmot-membership.md | 2 +- docs/member-chronicle.md | 2 +- docs/shared-key-ceremony.md | 20 +++++-- docs/shared-key-derivation.md | 2 +- docs/subgroups.md | 20 ++++--- 36 files changed, 320 insertions(+), 79 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupName.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotGroupNameTest.kt 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