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:
Kgothatso Ngako
2026-08-29 16:53:23 +02:00
parent bf94ecbe76
commit 9b3044f6fb
3 changed files with 129 additions and 11 deletions

View File

@@ -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

View File

@@ -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() {

View File

@@ -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()