feat(marmot): put a # in front of every group's name, and retire (#admins)
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-09 11:41:30 +02:00
1 parent 5c893ab108
commit 45cc80b538
36 files changed
+320 -79

No files matched your search

@@ -57,11 +57,11 @@
<string name="create_new_chat">Create new chat</string>
<string name="create_profile">Create profile</string>
<string name="create_project">Create project</string>
<string name="create_the_admins_group">Create the #admins group</string>
<string name="create_the_admin_room">Create the admin room</string>
<string name="agree_the_groups_signing_key">Agree the group's signing key</string>
<string name="the_group_is_agreeing_what_the_admins_room">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.</string>
<string name="the_group_has_agreed_what_the_admins_room">The group has agreed what the #admins room will sign with.</string>
<string name="the_group_could_not_agree_what_the_admins">The group could not agree what the #admins room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
<string name="the_group_is_agreeing_what_the_admin_room">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.</string>
<string name="the_group_has_agreed_what_the_admin_room">The group has agreed what the admin room will sign with.</string>
<string name="the_group_could_not_agree_what_the_admin">The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
<string name="before_the_room_exists_the_group_signs">Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.</string>
<string name="creating_new_chat">Creating new chat.</string>
<string name="currently_no_contacts_please_search_and_chat">Currently no contacts. Please search and chat with a few people.</string>
@@ -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.
*/
@@ -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**.
*
@@ -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`.
//
@@ -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.
@@ -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.
@@ -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.
@@ -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.")
@@ -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
}
}
@@ -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
@@ -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`.
*
@@ -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.
@@ -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<List<LocalFrostSigningSession>>
/**
* 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
@@ -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)
}
)
}
@@ -421,7 +421,7 @@ private fun FrostSigningScreenPreview() {
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Group (#admins)",
subject = "#Ekklesia",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
@@ -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
@@ -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)) }
)
}
@@ -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())
@@ -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()
)
@@ -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<HexKey>()
/**
* 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<String> = mutableStateOf("")
val threshold: MutableState<Int> = 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") + "."
)
@@ -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<String> = 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
@@ -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("#"))
}
}
@@ -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
@@ -161,7 +161,7 @@ class GroupSignedEventDaoJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = member,
subject = "#admins",
subject = "#Ekklesia",
description = SharedKeyDerivation.describe("Admins"),
mlsGroupState = null,
)
@@ -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
)
@@ -191,7 +191,7 @@ class ChronicleApplyJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = user,
subject = "#admins",
subject = "#Ekklesia",
description = null,
mlsGroupState = null,
)
@@ -201,7 +201,7 @@ class ChronicleAssemblyJvmTest {
ChatRoom(
id = chatRoomId,
userPublicKey = newMember,
subject = "#admins",
subject = "#Ekklesia",
description = null,
mlsGroupState = null,
)
@@ -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(
@@ -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.
@@ -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))
@@ -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
)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+15 -5
View File
@@ -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.
+1 -1
View File
@@ -110,7 +110,7 @@ leak reaches the others.
`derive` and `marmotGroupId` both take `path: List<Long>`, 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.
+14 -6
View File
@@ -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