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:
Kgothatso Ngako
2026-09-08 23:50:05 +02:00
parent 6bd59e05fe
commit b10c79f9ed
2 changed files with 165 additions and 4 deletions

View File

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