"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>
All nine phases are built, one commit each. The phases are kept as written --
they are the reasoning, and the code reads better against the argument it came
from than against a summary of itself -- with a table of where the building
disagreed with the plan.
Six worth reading. `openCeremony` was never built, because a wrapper over two
repository calls the picker already makes would be a third name for one act.
`MarmotGroupCreation` is reached through the repository rather than called from a
view model, because view models here talk to repositories and managers take the
database. The guards' tests are in jvmTest rather than pure, because every refusal
reads the database and a pure version would test less. Phase 4 added two tags
rather than one, the second fixing a bug older than subgroups -- every robust
group has been arriving nameless on every device but its creator's. `stateFrom`
needed a third reader and a wrapper, because "tag present and unreadable" looks
identical to "absent" through a parser, and "refused" has to be distinguishable
from "none claimed". And Phase 8's two capability refusals short-circuited the
tests already written, which is how it came out that the fixtures had never had a
parent that could sign.
Two the plan got right and worth keeping if this is ever rewritten: the
key-package check moved to the picker on review, before a line was built, and it
is the difference between a subgroup failing in a second and failing after three
ceremonies; and the founding-roster rule has a test whose job is to fail the day
somebody adds the comparison that looks obviously missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A review pass over the plan committed in c1ce262f, against the code rather than
against the plan's own reasoning. Five things it left open, and three smaller
ones.
**Key packages are one-time-use, and the plan discovered that at step 4.**
`NostrDao` marks a bundle `consumed = true` as the device processes its own
Welcome, and `MarmotKeyPackageBundleDao` only ever returns one that is neither
consumed nor rotated -- so every group a member joins burns one, and a member who
is in the parent and has not published since has none left. Phase 6 refuses to
create a room when any member's package is missing, correctly, because the id is
derived and a half-created room occupies that address permanently. But that
refusal landed after a ChillDKG, a parent quorum and a child quorum had all
completed, each of which needed every selected admin present. Availability is now
a property of the picker -- prefetched as the screen opens, the way group creation
already does it, with the member marked, unselectable, and told to publish a new
one -- and Phase 6's check is restated as the backstop it should always have been,
since a package can be consumed elsewhere between the two.
**Nothing said where the subgroup's name came from.** `createAdminGroup`
synthesises "X (#admins)" and gets away with it because a group has one admin
room; a group has many subgroups and "X (#subgroup)" names none of them. The
picker takes a required name, it travels to `MarmotGroupData`, and it is copied
into the certificate so the parent's admins approve something legible rather than
a hash. That freezes it, which is the right trade and is now written down: the
name and the `p` tags are the *founding* roster, `certifies` deliberately does not
check either, and the Phase 7 list titles a row from the room where it has one and
only otherwise from the certificate. A test that must pass -- a certificate whose
name and members no longer match the room's -- guards the roster check somebody
will otherwise add.
**The ceremony room arrived nameless on everybody else's device.**
`getOrCreateNip17ChatRoom` already reads `parseSubject()` off the payload and
`ChillDkgRitualManager.broadcast` writes no subject tag, so a selected admin
watches an unnamed room appear with a ceremony running in it. One tag on the
proposal, and worth fixing for robust-group creation in the same breath, where the
subject reaches only the creator's own device.
**`ChatRoom.parentChatRoomId` must not be a foreign key**, which the three
neighbouring tables make it natural to get wrong: they all declare
`ForeignKey(onDelete = CASCADE)` onto ChatRoom, and a self-referential one would
mean deleting a parent room deletes every subgroup beneath it and, by their own
cascades, those rooms' messages, participants, key states and signed events.
RESTRICT is not the answer either. The pointer routinely names a room this device
does not have -- a subgroup member who was never in the parent has the id and
nothing else -- so a dangling value is the normal state and resolution is a lookup
allowed to return null.
**The chronicle gap was named on the parent's side and missed on the child's.** A
member welcomed into a subgroup after founding holds no key state either, since
`adopt` files one only from the signed event, so their verified parent link is
null. `SharedKeyDerivation.describe` gains a parent line beside the path, which
reaches them in the Welcome through the epoch-0 group context -- explicitly a hint
written by the room's creator, never promoted into the verified column, and
subordinate to a real key state wherever one exists. Chronicling the certificate
is the upgrade for both faces of the gap, and the section says what it would
actually cost: an apply-order slot and a decision about whether a room's chronicle
may carry an event its own key did not sign, which no chroniclable kind does.
Three smaller ones. The coordinator is load-bearing only for step 1: after the
ceremony fixes the participant set, any parent admin can propose the certificate
and any child admin the key state and the room, so a coordinator who drops out
strands nothing -- and the UI should offer the rung to whoever can act rather than
to whoever started. Two coordinators racing produce two certificates sharing a `d`
tag, which is a replacement rather than an accumulation, and `certificateFor`
takes the newest that verifies. And Phase 8 gains refusal rows for a blank name
and for a member with no key package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A group can make another group, and the child can prove where it came from. This
is the plan for that, in nine phases, written against the code at b50b1762 and
not yet built.
**A subgroup is an ordinary robust group plus one artefact.** Fresh ChillDKG key,
fresh room, fresh quorum, and a birth certificate -- the parent's signature over
the child's room id -- carried on the child's `GroupKeyState`. Deriving the child
at `m/9420/1/0` instead would cost no ceremony at all and was rejected: a derived
child is the parent wearing a different hat, administered by the parent's members
with the parent's quorum, when the whole point is that a different set of people
can act on their own. The certificate is a claim about lineage, never a
delegation of authority, and nothing here lets one group sign for the other.
**Four steps, in the only order they can happen.** The ceremony produces `K`, so
the child's id exists; the parent's quorum certifies that id; the child's quorum
signs a key state carrying the certificate; the coordinator creates the room. No
step is a policy choice -- each needs the one before it -- and the last is gated
on the key state for the same reason `createAdminGroup` already is.
**What the parent's admins actually sign is the argument that shaped the event.**
Taken literally the certificate is 32 opaque bytes produced by a ceremony most of
them were not in. So the content is exactly the new group id as specified, and
the tags carry the child's threshold key, the path and the admin set -- covered
by the same signature, since an id hashes over its tags -- which lets a signer's
device check `marmotGroupId(key, path) == content` before agreeing, and lets a
coordinator who lies about who is in the child do it in a field the parent's
signature covers.
**The whole certificate travels as JSON on the key state, not a bare signature.**
A signature plus a rule for rebuilding the event it covers is a rule that breaks
silently the first time the event's shape changes: a rebuild differing by one
byte hashes to an id whose signature fails, and is indistinguishable from a
forgery. A parent tag rides beside it as an index into the certificate rather
than a second source of truth -- Phase 3 drops any state carrying one without the
other, or the two disagreeing, so there is no state where the index is believed
and the certificate is not.
**The ceremony stays on gift wraps, and the reason is `mls-skipped-keys.md`.**
Holding all three steps in the parent's Marmot room is the better design and the
plan says so at length rather than dismissing it: the certificate already runs
there, and the key state and the ceremony move together or not at all, since both
`GroupKeyStateManager.propose` and `signingPath` tie a key state to the room its
ceremony ran in. The mechanical cost is three enumerable changes. The reason to
wait is that the skipped-keys note already lists `proposeRitual` as a reliable
trigger, and a DKG cannot finish without every participant -- so one message
dropped for good stalls it permanently, where FROST needs `t` of `n` and routes
around a lost nonce. Revisit when the quartz fix lands; the collision Phase 4
refuses disappears with it.
**Three admins in total, and the threshold is set before anything is published.**
Three is `ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE` for the reason that constant
gives, and the coordinator counts because they hold a share by construction, so
the picker asks for two others. `t` has to be chosen on that same screen and
nowhere later: ChillDKG hashes it and the host keys into the session identity, so
it is fixed the moment the proposal goes out, and a group that disagrees about it
gets no key rather than a weak one.
The nine phases are ordered so the checkable parts come first and can ship dark:
the certificate and its verifier are pure, the schema is three nullable columns,
and nothing produces a certificate until the button in Phase 7 exists. Phase 6
extracts the 120 lines of Marmot room creation out of `DkgRitualViewModel` so
both flows share the rules that are already right there.
What it does not do is named rather than left to be found: no revocation, no
delegation, certificates are not chroniclable, one subgroup per admin set, and
every selected admin has to show up twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>