From 9b3044f6fbf9eda4a9f909f982bed14737406209 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 29 Aug 2026 16:53:23 +0200 Subject: [PATCH] feat: let robust groups choose their own quorum Picking Robust now asks how many of the admins have to approve a change instead of silently assuming a simple majority. The majority is still where the answer starts; it is just no longer the only one available. database/model/types/ChatRoomType.kt * approvalThreshold(adminCount) becomes defaultQuorum(adminCount): same simple majority, but named for what it now is -- an opening position rather than the rule. * MINIMUM_QUORUM = 2. One approval is not a quorum, it is one person acting alone, which is what CONVENIENT already offers. * quorumRange(adminCount) = MINIMUM_QUORUM..adminCount -- never fewer than two, never more admins than exist to approve. Because ROBUST is gated at MINIMUM_ROBUST_GROUP_SIZE = 3, the range always holds at least two choices, so the picker is never a control with nothing to pick. ui/view/model/SelectChatRoomTypeViewModel.kt * The fixed approvalThreshold field becomes quorum: MutableState, seeded from defaultQuorum(adminCount), alongside the quorumRange the UI clamps against. * setQuorum() coerces into quorumRange, so the value cannot escape the bounds even if the buttons' own enablement is wrong, and freezes once createdChatRoomId is set -- past creation the governance is already stamped into the epoch-0 group context, exactly as selectChatRoomType does. * quorumExplanation() states what the choice costs day to day: "Any 3 of you can approve a change -- the other 2 don't have to be around", and at the top of the range "Every admin has to agree. If one of you goes quiet, nothing about the group can change." Unanimity is a real liveness risk and the user should read that before choosing it, not after. ui/composable/SelectChatRoomTypeScreen.kt * ChatRoomTypeCard gains a trailing content slot, and the robust card fills it with the new QuorumPicker -- but only while robust is the selected type. Before that there is no decision to make and the question would be noise. * QuorumPicker is a stepper, not a text field: the range is small, both ends are bounded, and a stepper cannot produce a value that has to be rejected. The -/+ buttons disable at quorumRange.first/last and the caption re-reads on every step. * The robust card's own prose drops the hard number -- "approved by a quorum of you" rather than "approved by 3 of the 5 admins" -- because the number is now a choice rather than a fact, and the picker is the thing that states it. Not done, and called out in the TODO next to the admin list: the chosen quorum is not persisted. It cannot ride in MarmotGroupData -- MIP-01's wire format is fixed and an extra field would break byte-compatibility with mdk/whitenoise -- so it needs a ChatRoom column and the Room migration off schema version 1 that comes with it. Until then the quorum is a stated intent sitting beside the admin list, in the same way the t-of-n enforcement itself is still waiting on FROST signing over admin changes. Verified: ./gradlew :composeApp:compileCommonMainKotlinMetadata ./gradlew :composeApp:compileDebugKotlinAndroid Co-Authored-By: Claude Opus 5 --- .../database/model/types/ChatRoomType.kt | 19 +++- .../ui/composable/SelectChatRoomTypeScreen.kt | 87 +++++++++++++++++-- .../view/model/SelectChatRoomTypeViewModel.kt | 34 +++++++- 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt index d44108a6..33449269 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/ChatRoomType.kt @@ -29,10 +29,23 @@ enum class ChatRoomType { const val MINIMUM_ROBUST_GROUP_SIZE = 3 /** - * How many of [adminCount] admins have to approve a change — a simple - * majority, so no half of the group can move without the other. + * Fewest admins a [ROBUST] room can be made to run on. One approval is not a + * quorum — it is one person acting alone, which is [CONVENIENT]. */ - fun approvalThreshold(adminCount: Int): Int = adminCount / 2 + 1 + const val MINIMUM_QUORUM = 2 + + /** + * The quorum a [ROBUST] group of [adminCount] starts on — a simple majority, + * so no half of the group can move without the other. The user can move it + * anywhere in [quorumRange] from there. + */ + fun defaultQuorum(adminCount: Int): Int = maxOf(MINIMUM_QUORUM, adminCount / 2 + 1) + + /** + * Quorums a [ROBUST] group of [adminCount] can be run on: never fewer than + * [MINIMUM_QUORUM], never more than the admins who exist to approve. + */ + fun quorumRange(adminCount: Int): IntRange = MINIMUM_QUORUM..adminCount /** Whether a group of [memberCount] people can be run as [ROBUST]. */ fun isRobustAvailable(memberCount: Int): Boolean = memberCount >= MINIMUM_ROBUST_GROUP_SIZE diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 2128c365..27131b7b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -22,6 +24,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton @@ -102,8 +105,8 @@ fun SelectChatRoomTypeScreen( val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded val adminCount = selectChatRoomTypeViewModel.adminCount - val approvalThreshold = selectChatRoomTypeViewModel.approvalThreshold val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable + val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST Scaffold( topBar = { @@ -209,18 +212,30 @@ fun SelectChatRoomTypeScreen( title = "Robust", summary = "Every member is an admin.", detail = if (isRobustAvailable) { - "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by $approvalThreshold of the $adminCount admins before it takes effect." + "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." } else { - "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a majority of the admins before it takes effect." + "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." }, footnote = "No single admin can change the group alone, and the group outlives any one of you.", - isSelected = selectedChatRoomType == ChatRoomType.ROBUST, + isSelected = isRobustSelected, isEnabled = isRobustAvailable, disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, onClick = { selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) } - ) + ) { + // Ask for the quorum only once robust is the choice — before that + // there is no decision to make. + if (isRobustSelected) { + QuorumPicker( + quorum = selectChatRoomTypeViewModel.quorum.value, + adminCount = adminCount, + quorumRange = selectChatRoomTypeViewModel.quorumRange, + explanation = selectChatRoomTypeViewModel.quorumExplanation(), + onQuorumChange = selectChatRoomTypeViewModel::setQuorum + ) + } + } } } } @@ -273,6 +288,7 @@ private fun ChatRoomTypeCard( onClick: () -> Unit, isEnabled: Boolean = true, disabledReason: String? = null, + content: @Composable () -> Unit = {}, ) { Card( modifier = Modifier.fillMaxWidth(), @@ -335,10 +351,71 @@ private fun ChatRoomTypeCard( color = MaterialTheme.colorScheme.error ) } + + content() } } } +/** + * Asks how many admins have to sign off on a change. Stepped rather than typed: + * the range is small, both ends are bounded, and the caption has to keep up. + */ +@Composable +private fun QuorumPicker( + quorum: Int, + adminCount: Int, + quorumRange: IntRange, + explanation: String, + onQuorumChange: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + text = "How many admins have to approve a change?", + style = MaterialTheme.typography.titleSmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton( + onClick = { onQuorumChange(quorum - 1) }, + enabled = quorum > quorumRange.first + ) { + Icon( + Icons.Default.Remove, + contentDescription = "Fewer approvals" + ) + } + + Text( + text = "$quorum of $adminCount", + style = MaterialTheme.typography.titleMedium + ) + + FilledIconButton( + onClick = { onQuorumChange(quorum + 1) }, + enabled = quorum < quorumRange.last + ) { + Icon( + Icons.Default.Add, + contentDescription = "More approvals" + ) + } + } + + Text( + text = explanation, + style = MaterialTheme.typography.labelMedium + ) + } +} + @Preview @Composable private fun SelectChatRoomTypeScreenPreview() { 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 ac316829..24498cd0 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 @@ -87,8 +87,14 @@ class SelectChatRoomTypeViewModel( */ val adminCount: Int = (memberPublicKeys + activeUserPublicKey).distinct().size - /** How many of [adminCount] admins a change needs, under [ChatRoomType.ROBUST]. */ - val approvalThreshold: Int = ChatRoomType.approvalThreshold(adminCount) + /** Quorums this group can be run on, under [ChatRoomType.ROBUST]. */ + val quorumRange: IntRange = ChatRoomType.quorumRange(adminCount) + + /** + * How many of [adminCount] admins have to approve a change, under + * [ChatRoomType.ROBUST]. Starts on a simple majority and is the user's to move. + */ + val quorum: MutableState = mutableStateOf(ChatRoomType.defaultQuorum(adminCount)) /** Whether this group is big enough to be run as [ChatRoomType.ROBUST]. */ val isRobustAvailable: Boolean = ChatRoomType.isRobustAvailable(adminCount) @@ -127,6 +133,25 @@ class SelectChatRoomTypeViewModel( selectedChatRoomType.value = chatRoomType } + fun setQuorum(value: Int) { + // Same freeze as the type itself: past creation the group's governance is + // already stamped into its epoch-0 context. + if (isActionPending.value || createdChatRoomId.value != null) return + + quorum.value = value.coerceIn(quorumRange) + } + + /** Spells out what the chosen quorum costs the group day to day. */ + fun quorumExplanation(): String { + val chosenQuorum = quorum.value + + return if (chosenQuorum == adminCount) { + "Every admin has to agree. If one of you goes quiet, nothing about the group can change." + } else { + "Any $chosenQuorum of you can approve a change — the other ${adminCount - chosenQuorum} don't have to be around." + } + } + /** Names the member behind [publicKey], falling back to the key itself. */ fun displayNameFor(publicKey: HexKey): String { val members = (selectChatRoomTypeUIState as? SelectChatRoomTypeUIState.Loaded)?.members @@ -175,7 +200,10 @@ class SelectChatRoomTypeViewModel( // exactly this list. // TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST // signing over admin changes. Today the list is set but every admin can - // still commit on their own. + // still commit on their own, and the quorum the user picked has nowhere to + // live: MIP-01's wire format is fixed, so it cannot ride in MarmotGroupData + // without breaking byte-compatibility with mdk/whitenoise. It needs a + // ChatRoom column (and the Room migration that comes with it). val adminPubkeys = when (selectedChatRoomType.value) { ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey()) ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct()