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<Int>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Int> = 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()
|
||||
|
||||
Reference in New Issue
Block a user