feat(subgroups): the refusals that are about capability, not preference
Phase 8 of docs/subgroups.md. Four of the seven refusals landed with the code they guard; these are the three that did not, and two of them are about what this device *can* do rather than what the user should pick. **No share of the parent's key.** `FrostSigningManager.proposeSigningBatch` throws outright for a device with no share, so without this the button crashes rather than declines. It is checked first because it is the one refusal a user cannot fix by picking differently -- every other message says "pick differently" and this one cannot. **Not an admin of the parent.** A non-admin proposing the group's signature is a proposal the admins have to decline by hand, which is worse than not offering it. Read off `Participant.adminAt`, which is the epoch-0 admin list MIP-01 carries and the same reading every side of the group makes. Both are checked in `refuseCeremonyRoom` as well as at the button that hides itself on `canAddSubgroup`, because a screen not drawing something is not a guard -- and a resumed flow reaches the manager without passing that screen at all. **A child already certified.** 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 nothing. This is a `check` at propose time rather than a picker refusal, since the child's id does not exist until the ceremony finishes. It deliberately does not try to stop the race. Two coordinators can each propose a certificate for the same child, neither able to see the other's session before it completes; `certificateFor` folds those together because both say the same true thing. This only stops the case somebody can actually see. The blank-name `require` gets a test of its own for the same reason the name exists at all: without it the parent's admins would be approving a hash. Six tests in `SubgroupManagerJvmTest`, and the fixture had to grow to carry them. `parentWith` now gives the parent a completed ceremony and marks the coordinator an admin, with both switchable, because a fixture missing either tests only the first refusal -- which is how the two new checks were found to short-circuit the existing cases the moment they were added. The plan called for a pure `SubgroupGuardsTest` in commonTest. It is not one: every refusal here reads the database -- a key-holding session, an admin flag, a ceremony in the derived room -- so the tests live in jvmTest beside the manager's others rather than being reshaped into something pure that would test less. 397 common tests, 705 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,28 @@ object SubgroupManager {
|
||||
coordinatorPublicKey: HexKey
|
||||
): String? {
|
||||
val admins = adminPublicKeys + coordinatorPublicKey
|
||||
val parentChatRoomId = parentRoom.chatRoom.id
|
||||
|
||||
// A subgroup is certified by the parent's key, so a device holding no
|
||||
// share of it cannot open the session at all --
|
||||
// `FrostSigningManager.proposeSigningBatch` throws outright, and throwing
|
||||
// at a button is not a UI. Checked before the size rules because it is the
|
||||
// one refusal the user cannot fix by picking differently.
|
||||
if (!FrostSigningManager.canSign(database, parentChatRoomId)) {
|
||||
return "Only a member who holds a share of this group's key can make a subgroup."
|
||||
}
|
||||
|
||||
// A non-admin proposing the group's signature is a proposal the admins
|
||||
// have to decline by hand, which is a worse outcome than not offering it.
|
||||
// Read off the epoch-0 admin list MIP-01 carries, the same reading every
|
||||
// side of the group makes.
|
||||
val isAdmin = parentRoom.localParticipants.any {
|
||||
it.participant.participantPublicKey == coordinatorPublicKey &&
|
||||
it.participant.adminAt != null
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return "Only an admin of this group can make a subgroup."
|
||||
}
|
||||
|
||||
if (admins.size < MINIMUM_ADMINS) {
|
||||
return "A subgroup needs at least $MINIMUM_ADMINS admins. Pick at least " +
|
||||
@@ -168,6 +190,16 @@ object SubgroupManager {
|
||||
|
||||
require(name.isNotBlank()) { "A subgroup has to be called something" }
|
||||
|
||||
// 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
|
||||
// nothing. Two coordinators racing still produce two -- neither can see
|
||||
// the other's session before it completes -- and `certificateFor` folds
|
||||
// those together; this only stops the case somebody can actually see.
|
||||
check(certificateFor(database, subgroupChatRoomId, parentChatRoomId) == null) {
|
||||
"$parentChatRoomId has already certified $subgroupChatRoomId"
|
||||
}
|
||||
|
||||
logger.i("Asking $parentChatRoomId to certify $subgroupChatRoomId as its subgroup")
|
||||
|
||||
return FrostSigningManager.proposeSigning(
|
||||
|
||||
Reference in New Issue
Block a user