fix(subgroups): a subgroup may be the whole group

"A subgroup cannot be the whole group. Leave at least one member out." That rule
shipped in Phase 8 and it was wrong twice.

**It refused something legitimate.** A subgroup is a logical division -- a group
deciding that some of its work belongs to a differently-keyed room -- not a group
carving out a smaller membership. Every member being in it is an ordinary case,
and no guard here had any business deciding otherwise.

**And it was a proxy, not a check.** The thing it stood in for is real: a ceremony
room is derived from its admins, so a subgroup over everybody lands in the room
the group's *own* ceremony was held in, and `proposeRitual` handing back that
ceremony would quietly make the child the parent. But set size does not detect
that. A parent whose membership has changed since its own ceremony derives a
different room -- so the sizes can match with no collision, and differ with one.

**Sharing the room was never the problem; sharing a ceremony was.**
`DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to
scope the lookup by it rather than to forbid the selection.
`DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the
three places that decide whether a ceremony already exists: `proposeRitual`'s
one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the
ritual screen follows. A room may now hold the group's own ceremony and a
subgroup's at once.

Nothing below that lookup had to learn about the second one. A ceremony's
messages, approvals and transcript are already keyed by session id; only the
question "what is this room's current ceremony" was ever room-scoped, and that
question was always really "for what purpose".

One collision survives and it is degenerate: the same parent, over the same
admins, twice. Those two have nothing left to distinguish them -- which is another
way of saying they are one subgroup asked for twice, and that is what the message
now says.

`a subgroup that is the whole group is refused` becomes `a subgroup may be the
whole group`, and two new cases pin the scoping: the group's own ceremony sitting
in the derived room does not block a subgroup there, and the same parent asking
twice over the same admins still does while a different admin set is untouched.

`docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table
with a section saying why, rather than quietly deleting it -- the reasoning that
led to it is the reasoning somebody would repeat.

397 common tests, 713 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-09 00:38:49 +02:00
parent ccaef5d36a
commit 1930d6aaef
8 changed files with 236 additions and 85 deletions

View File

@@ -398,11 +398,16 @@ comment:
- Selecting an admin set that already holds a completed ceremony returns that
ceremony (`proposeRitual` hands back anything not `FAILED`), which would make
the "new" subgroup the old group under a new name — same key, same room id.
- Selecting *every* member of the parent is the pathological case of that: the
parent's own ceremony room is the room over all its members, so the child would
come out as the parent itself.
- Selecting *every* member of the parent lands in the parent's own ceremony room,
which is the room over all its members.
Refused in Phase 8, on both readings, before the button is offered.
The second is **not** refused, and the first is refused more narrowly than it
first shipped. A subgroup over everybody is legitimate -- a subgroup is a logical
division rather than a smaller membership -- and sharing a room was never the
problem; sharing a *ceremony* was. `DkgSession.parentChatRoomId` tells two
ceremonies in one room apart, and `DkgSessionDao.getLatestSessionFor` is scoped by
it, so what Phase 8 refuses is only the same parent asking twice over the same
admins. See [that phase](#phase-8--the-refusals).
### Why not the parent's Marmot room
@@ -784,16 +789,43 @@ a room nobody can replace.
| a threshold outside `ChatRoomType.quorumRange(adminCount)` | `t = 1` is a threshold key any one member signs with, and ChillDKG will happily generate one. `acceptProposal` already refuses such a proposal, so an unchecked one produces a ceremony every invitee silently drops |
| an empty or blank name | Phase 6 has nothing to put in `MarmotGroupData.name`, and the parent's admins would be certifying an unnamed hash |
| a selected member with no unconsumed key package | they cannot be welcomed into the room at step 4, and finding that out at step 4 wastes three ceremonies. Checked at the picker and again at confirm |
| the selection is not a proper subset of the parent's members | selecting everybody derives the parent's own ceremony room, and the "child" comes out as the parent |
| the ceremony room already holds a non-`FAILED` `DkgSession` | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id |
| ~~the selection is not a proper subset of the parent's members~~ | **withdrawn.** A subgroup is a logical division, not a smaller membership, so "everybody" is a normal answer. What this was standing in for is the row below, checked precisely rather than by set size |
| the ceremony room already holds a non-`FAILED` `DkgSession` **for this same parent** | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id. Scoped by `DkgSession.parentChatRoomId`, so a room may hold the group's own ceremony *and* a subgroup's |
| this device holds no share of the parent's key | it cannot open the certificate session; `proposeSigningBatch` throws, and throwing at the button is not a UI |
| this device is not an admin of the parent | a non-admin proposing the parent's signature is a proposal the parent's admins have to decline by hand |
| the parent has no signed key state and no resolvable key | there is nothing for the certificate to be signed with |
| a certificate already stands for this child | one certificate per child; a second is a `d`-tag replacement of the first, not a second subgroup |
The first and the third are the ones a user will actually meet, so both say what
to do: "pick at least two more people" and "a subgroup cannot be the whole group
— leave at least one member out".
The first is the one a user will actually meet, and it says what to do: "pick at
least two more people".
### Why "a subgroup cannot be the whole group" was withdrawn
It shipped, and it was wrong twice.
It refused something legitimate. A subgroup is a group deciding that some of its
work belongs to a differently-keyed room, not a group carving out a smaller
membership — so every member being in it is an ordinary case, and no rule here had
any business deciding otherwise.
And it was a proxy rather than a check. The thing it stood in for is real: a
ceremony room is derived from its admins, so a subgroup over everybody lands in
the room the group's *own* ceremony was held in, and `proposeRitual` handing back
that ceremony would make the child the parent. But set size does not detect that.
A parent whose membership has changed since its own ceremony derives a different
room, so the sizes can match with no collision and differ with one.
**Sharing the room was never the problem; sharing a ceremony was.**
`DkgSession.parentChatRoomId` already told them apart, so the fix was to scope the
lookup by it — `DkgSessionDao.getLatestSessionFor(room, parent)` — rather than to
forbid the selection. A room may now hold two ceremonies, and everything below
that lookup already keyed off the session id, so nothing else had to learn about
the second one.
One collision survives, and it is degenerate: the same parent, over the same
admins, twice. Those two have nothing left to distinguish them, which is another
way of saying they are one subgroup asked for twice, and that is what the message
now says.
## Phase 9 — the tests that prove it
@@ -871,12 +903,16 @@ about whether the child's chronicle may carry an event its own key did not sign,
which no chroniclable kind does today. That last one is the real work, and it is
why this is a follow-up rather than a line in Phase 5.
**One admin set, one subgroup.** Phase 4's collision is refused, not solved. Two
subgroups with exactly the same admins need the NIP-17 ceremony room to be
distinguishable by something other than its members, and
`ChatRoom.deriveChatRoomId` is a pure aggregate of member keys that the inbound
path recomputes. Changing it is a change to how every NIP-17 room in the app is
addressed, and it is not worth making for this.
**One parent, one admin set, one subgroup.** Narrower than it first shipped: a
room may hold two ceremonies, told apart by `DkgSession.parentChatRoomId`, so a
group's own ceremony and a subgroup over the same people coexist. What is still
refused is the same parent asking twice over the same admins, which have nothing
left to distinguish them.
Widening that further needs the NIP-17 ceremony room to be distinguishable by
something other than its members, and `ChatRoom.deriveChatRoomId` is a pure
aggregate of member keys that the inbound path recomputes. Changing it is a change
to how every NIP-17 room in the app is addressed.
It is also the limitation that disappears on its own: move the ceremony into the
parent's Marmot room, as [Phase 4](#why-not-the-parents-marmot-room) defers doing,