docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
# Subgroups
A group makes another group, and the child can prove where it came from. This is
how the four ceremonies fit together, what the parent's signature actually
covers, and the one thing a subgroup cannot inherit.
Read [shared-key-derivation.md ](./shared-key-derivation.md ) and
[shared-key-ceremony.md ](./shared-key-ceremony.md ) first. Everything below is
built on the property they state — that a room's id * is * the key it signs with —
and on the ceremony they describe, which a subgroup runs again from scratch.
docs: record what the subgroups plan built, and the six places it chose differently
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>
2026-09-08 23:52:19 +02:00
**Built**, phases 1-9, one commit each. The phases are kept as written because
they are the reasoning, and the code reads better against the argument it came
from than against a summary of itself. Where the implementation chose differently
the section says so, and it did so six times worth reading:
| what the plan said | what it turned out to be |
|---|---|
| `SubgroupManager.openCeremony` | never built. The picker calls `createNip17ChatRoom` and `proposeRitual` directly, exactly as robust-group creation does -- a wrapper over two repository calls would have been a third name for one act |
| `MarmotGroupCreation.create` called from the view model | reached through `ChatRepository.createMarmotGroup` . View models here talk to repositories and managers take the database; the first cut reached for a `database` the interface does not expose, and should not |
| a pure `SubgroupGuardsTest` in commonTest | jvmTest. Every refusal reads the database -- a key-holding session, an admin flag, a ceremony in the derived room -- so a pure version would have tested less, not differently |
| Phase 4 adds one tag | two. `subject` fixes a bug older than subgroups: `getOrCreateNip17ChatRoom` has always read a subject off the payload and the ritual path never wrote one, so every robust group arrived nameless on every device but its creator's |
| `stateFrom` reads the parentage with two parsers | three, and a wrapper. A tag present and unreadable looks absent through a parser, so `claimsParentage` reads the tag * names * ; and "refused" has to be told from "none claimed", which a bare nullable cannot carry |
| Phase 8 adds three refusals to a settled function | it short-circuited the tests that were already there. `canSign` and `isAdmin` run before the picking rules, so the fixtures had to grow a parent that can actually sign and a coordinator who is actually an admin -- see the note in that phase |
Two things the plan got right that are worth keeping if any of this is rewritten.
The key-package check moved to the picker before a line of it was built, on
review, and it is the difference between a subgroup failing in a second and
failing after three ceremonies. And the founding-roster rule -- that `certifies`
must not compare the name or the `p` tags against anything current -- has a test
whose job is to * fail * the day somebody adds the comparison that looks obviously
missing.
Written against the code as it stood at `b50b1762` .
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## What a subgroup is
A subgroup is an ordinary Marmot room with an ordinary threshold key, made by an
ordinary ChillDKG ceremony, plus a **birth certificate ** — the parent group's
signature over the child's room id — carried on the child's key state.
The key state grows two tags for it, not one, and only the first is an artefact:
| tag | what it is |
|---|---|
| `birth_certificate` | the parent's signed statement, whole. The claim itself |
| `parent_group` | the parent's room id — an **index ** into the certificate, which already carries the same value in its own `parent_group` tag and as its author |
The second is redundant by construction and is kept anyway, so a reader can
answer "whose child is this" without parsing an event out of a tag value. What
stops it being a second, weaker source of truth is Phase 3's rule that a state
carrying one without the other, or the two disagreeing, is dropped entirely.
There is no state in which the index is believed and the certificate is not.
That is the whole of the relationship. The child does not derive its key from the
parent's, does not share a share with it, and cannot be signed for by it. What
the certificate buys is a checkable claim, and only that claim:
> The group holding key `P` said, with a quorum, that the room `C` is its child.
Everything else about a subgroup — how it signs, who administers it, what it
holds — is the same as any other robust group in this app, and deliberately so.
### Why the key is fresh rather than derived
`SharedKeyDerivation` can already walk a group's key to any path, and
`m/9420/1/0` would give a child room for free with no ceremony at all. It is the
wrong answer for one reason, stated in that file:
> Anyone who learns one derived private key recovers the threshold key and can
> sign as the group without any quorum at all.
A derived child is the parent wearing a different hat. Its members would be the
parent's members, its quorum the parent's quorum, and the parent's admins would
be able to sign as it. A subgroup exists precisely so that a * different * set of
people — including people the parent does not trust to administer the parent —
can act on their own. A fresh ceremony is what makes the child's authority the
child's.
The cost is that a subgroup is not cheap: four multi-party rounds, every one of
which needs every selected member to show up. That is named again under
[what this does not do ](#what-this-does-not-do ).
## The order, and why it is forced
```
1. ChillDKG among the new admins -> threshold key K, and so C = marmotGroupId(K)
2. birth certificate, in the parent -> parent's quorum signs C
3. key state, among the new admins -> child's quorum signs "C signs with K", carrying the certificate
4. the Marmot room -> coordinator creates C and welcomes the admins
```
Nothing here is a policy choice. Step 2 needs `C` , which does not exist until
step 1 produces `K` . Step 3 carries the certificate, so it needs step 2. Step 4
is gated on the key state for the same reason `DkgRitualViewModel.createAdminGroup`
is today — a room created before its group has agreed what it signs with is a
room whose founding fact is settled after the founding.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
The coordinator is the parent admin who pressed the button. They coordinate the
child's ceremony, propose the certificate in the parent, propose the key state in
the child, and create the room. ChillDKG and FROST both treat a coordinator as
untrusted, so this buys them nothing but work — the same bargain the existing
ceremony makes.
**Only step 1 is theirs alone.** After the ceremony fixes the participant set,
every remaining step is open to somebody else: the certificate can be proposed by
any parent admin holding a share of the parent's key, and the key state and the
room by any of the child's admins, who by then all hold shares of `K` and know
`C` . So a coordinator whose phone dies after the ceremony does not strand a
subgroup — the flow is resumable by anyone who was in it, which is why every step
reads its state off stored rows rather than off a session object. The UI should
make that reachable rather than merely true: Phase 7's rung is offered to whoever
opens the screen and can act, not only to the member who started.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## Three ceremonies, two quorums, one coordinator
| step | runs in | signed by | transport |
|---|---|---|---|
| ChillDKG | a NIP-17 room over the child's admins | n/a — every participant | gift wraps |
| birth certificate | the **parent ** Marmot room | the parent's quorum, as the parent's key | MLS group events |
| key state | the child's NIP-17 ceremony room | the **child's ** quorum, as the child's key | gift wraps |
The middle row is the only new shape, and it is not new machinery:
`FrostSigningManager` already runs on both transports and already gates each
signer's approval behind the existing proposal UI. What is new is a kind for it
to carry and a reason for the parent's admins to say yes.
## Informed consent, and what the parent's admins are actually signing
The spec for the certificate is "the hex of the new groupId, signed with the
existing groupId's key". Taken literally that is 32 opaque bytes: a parent admin
would be asked to put the group's signature to a number they cannot check,
produced by a ceremony most of them were not in, on behalf of people they have
only the coordinator's word about.
So the **content ** is exactly the new group id, as specified, and the **tags **
carry what makes it checkable. The signature covers both — an event id hashes
over its tags — so nothing is added to the claim by putting it there, only to
what a signer can see before agreeing:
```
kind: 30329
pubkey: <the parent room's id> (set by the signing session, not the proposer)
content: <the child's room id, 32-byte hex>
tags:
["d", <the child's room id>] addressable; newest certificate per child wins
["parent_group", <the parent room's id>]
["subgroup_key", <the child's threshold key, 33-byte hex>]
["frost_path", "m/9420/0/0"]
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
["name", <the subgroup's name, as the coordinator typed it>]
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
["p", <each of the child's admins>]
```
With those, a parent admin's device can check the thing that actually matters
before it signs — `marmotGroupId(subgroup_key, frost_path) == content` — and
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
`ProposedEvent` can render "Translation team, for Alice, Bob and Carol" rather
than a hash. A coordinator who lies about who is in the child is then lying in a
field the parent's signature covers, which is the difference between a mistake
and evidence.
The name is in there for the same reason and not for the room's benefit: the room
takes its name from `MarmotGroupData` like every other Marmot room, and this copy
exists so that what the parent approved is legible in the transcript and in the
proposal screen. Two consequences to hold:
**The name and the `p` tags are the founding roster, and they are frozen.** A
certificate is signed once and members move afterwards — somebody is added to the
child, somebody leaves, the room is renamed. None of that reaches a signature
already made, and none of it should: the certificate says who the parent
certified and under what name, which is a historical fact and stays true. So the
UI must never render either as the * current * state of the subgroup. Where the
room exists locally, its own row is the live answer; the certificate is what it
was born as. The Phase 7 list follows that rule and the plan says so again there.
**A rename therefore drifts from what was signed, on purpose.** The alternative —
leaving the name out so nothing can drift — buys consistency by making the
parent's admins approve an unnamed hash, which is the problem this section
exists to fix. Drift in a historical record is not an error; an unreadable
approval is.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## Phase 1 — the certificate, and checking one
New package `nostr/subgroup/` , no database, no coroutines — the same shape as
`GroupKeyStateEvent` , and testable the same way.
**`SubgroupBirthCertificateEvent` ** at kind **30329 ** , the next free kind after
`ChronicleRequestEvent` (30328). Sits in the private 303xx range with everything
else that never leaves an encryption.
```kotlin
object SubgroupBirthCertificateEvent {
val KIND: Kind = 30329
fun assembleTags(
subgroupChatRoomId: String,
parentChatRoomId: String,
thresholdPublicKey: HexKey,
adminPublicKeys: List<HexKey>,
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
name: String,
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
): Array<Array<String>>
fun parseSubgroupChatRoomId(tags: Array<Array<String>>): String?
fun parseParentChatRoomId(tags: Array<Array<String>>): String?
fun parseThresholdPublicKey(tags: Array<Array<String>>): HexKey?
fun parsePath(tags: Array<Array<String>>): List<Long>?
fun parseAdminPublicKeys(tags: Array<Array<String>>): List<HexKey>
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
fun parseName(tags: Array<Array<String>>): String?
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
/** Whether [event] is a certificate the room [parentChatRoomId] actually signed for [subgroupChatRoomId]. */
fun certifies(event: Event, subgroupChatRoomId: String, parentChatRoomId: String): Boolean
}
```
`certifies` is where the whole of the trust in a parent link lives, and it is six
questions, all answerable from the event:
1. the kind is 30329;
2. `content == subgroupChatRoomId` , and the `d` tag agrees with it;
3. the `parent_group` tag is `parentChatRoomId` ;
4. `SharedKeyDerivation.marmotGroupId(subgroup_key, frost_path) == content` — the
id really is that key's room, so a certificate cannot be pointed at a room the
key does not derive;
5. `GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId)` — the author is
the parent's id, the id is the hash of the fields beside it, and the signature
verifies;
6. everything above inside a `runCatching` , because every input is off the wire.
Point 5 is `GroupKeyStateEvent.isSignedByRoom` used verbatim, not reimplemented.
It already asks "did * this room * sign this", and a room id is a public key here —
that is the whole economy of `docs/member-chronicle.md` and it applies unchanged.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**The name and the `p` tags are deliberately not among the six.** They are
covered by the signature — everything in the tags is — but nothing downstream may
*require* them to match anything, because they are the founding roster and the
world moves. A `certifies` that compared the `p` tags against the room's current
members would start rejecting a valid certificate the first time somebody joined
the child, and the failure would look like a forgery.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
Two tag classes beside it, in `nostr/subgroup/tags/` , matching
`FrostDerivationPathTag` 's shape: `SubgroupParentTag` (`parent_group` ) and
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
`SubgroupKeyTag` (`subgroup_key` ). The `d` and `p` tags are quartz's; `name` is a
bare two-element tag with no class of its own, since nothing parses it but the
proposal screen.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
**Tests** — `SubgroupBirthCertificateEventTest` , commonTest, pure: a certificate
that verifies; one whose content does not match its `d` tag; one whose id does
not derive from its key; one signed by a different room; one whose signature is
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
64 bytes of nonsense; one whose key is not a point on the curve; and one whose
name and admin set have nothing to do with the room's current ones, which must
still verify — that last is there to fail loudly if anybody later adds the
roster check the paragraph above forbids.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## Phase 2 — schema 16 → 17
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
Four nullable columns and nothing else, so Room migrates itself and the entry
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
joins the list in `MantraDatabase` .
| table | column | filled from | trusted? |
|---|---|---|---|
| `GroupKeyState` | `parentChatRoomId` | the state's parent tag | **yes ** — `stateFrom` verified the certificate |
| `GroupKeyState` | `birthCertificateJson` | the state's certificate tag | **yes ** — same |
| `ChatRoom` | `parentChatRoomId` | the child's key state, as the room is created or adopted | **yes ** |
| `DkgSession` | `parentChatRoomId` | a tag on the ceremony proposal | **no ** — a hint for the UI, see Phase 4 |
The trust column is the point of the table. Two of these are written only after a
signature has been checked and one is written from an unauthenticated claim on a
wire message; a column that mixes the two is a column no reader can act on. The
`DkgSession` one is never read for anything but a screen's title.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
### `parentChatRoomId` is a plain column, never a foreign key
Worth its own heading because the reflex points the wrong way. `GroupKeyState` ,
`DkgSession` and `GroupSignedEvent` each declare
```kotlin
ForeignKey(entity = ChatRoom::class, parentColumns = ["id"], childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE)
```
and the obvious next move is to give the parent pointer the same treatment. It
must not have it. A self-referential foreign key on `ChatRoom` with `CASCADE`
means deleting a parent room deletes every subgroup row beneath it — and then,
by their own cascades, each subgroup's messages, participants, key state, signing
sessions and signed events. A user tidying up a group they have left would silently
destroy a group they are still in.
`RESTRICT` is not the answer either: it would make a parent undeletable while any
child row exists, which is a foreign key deciding a product question. The parent
pointer is a **reference to a room that may not be on this device at all ** — a
member of a subgroup who was never in its parent has the id and nothing else —
so it cannot be a foreign key in the first place. A dangling pointer is the
normal, expected state, and readers resolve it with a lookup that is allowed to
return null.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
The whole certificate event is stored as JSON rather than the signature alone.
That is deliberate and is argued in the [appendix ](#appendix--what-was-considered-and-rejected ):
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.
New DAO reads, all straightforward:
- `GroupKeyStateDao.getByParentChatRoomId(parentChatRoomId)` and an
`observe` beside it.
- `ChatRoomDao.observeByParentChatRoomId(parentChatRoomId)` .
- `DkgSessionDao.getByParentChatRoomId(parentChatRoomId)` , for resuming a
ceremony whose room has not been made yet.
## Phase 3 — a key state that names its parent
`GroupKeyStateEvent` gains two optional tags, and `GroupKeyStateManager` gains
the checks that make them mean something.
```
["parent_group", <the parent room's id>]
["birth_certificate", <the signed 30329 event, as JSON>]
```
`assembleTags` takes an optional `parent: SubgroupParentage?` carrying both, and
emits neither when it is null — so every existing call site and every state
already signed is untouched.
The work is in `stateFrom` , which is where a state earns its row. After the two
checks it already makes — the room rederives from the key, and the group signed
it — a state carrying either new tag has to pass four more:
1. **Both or neither. ** A parent tag with no certificate, or a certificate with
no parent tag, is dropped. A half-claim is not a weaker claim; it is a claim
with the checkable part removed.
2. The certificate parses as an `Event` .
3. `SubgroupBirthCertificateEvent.certifies(certificate, state.chatRoomId, parentChatRoomId)`
— the parent signed * this * child, not some other one.
4. The certificate's `subgroup_key` equals the state's own threshold key.
Redundant with (3) via the derivation, and kept because it costs nothing and
the two facts are stated in different places.
A failure at any of them **drops the whole state ** , rather than keeping it as a
parentless one. A state that claims a parentage it cannot back is not a state
with one field wrong — it is a device asserting a relationship the parent group
never agreed to, and half-believing it is worse than believing none of it.
`GroupKeyStateManager.propose` takes the same optional parentage and passes it
through. `record` and `adopt` carry the two verified values onto the row; they
already funnel through `stateFrom` , so nothing else is needed to keep the row
honest.
**Tests** — added to `GroupKeyStateTest` (commonTest, pure `stateFrom` cases) and
`SignedGroupKeyStateTest` (jvmTest, real FROST signatures end to end): a subgroup
state that verifies; one whose certificate names another child; one whose
certificate is signed by a room that is not the named parent; one carrying a
parent tag and no certificate; one carrying a certificate whose key is not the
state's.
## Phase 4 — where the subgroup's ceremony runs
The child's ChillDKG needs a room whose participant set is exactly the child's
admins, and whose transport is gift wraps. There is already one:
`chatRepository.createNip17ChatRoom` over the selected admins plus the
coordinator. Its id is `ChatRoom.deriveChatRoomId` over the member set, so every
device lands on the same room, and `ChillDkgRitualManager` needs no change at all
— its `broadcast` p-tags come from that room's participants and `acceptProposal`
derives `n` from those same p-tags.
This is the same move `SelectChatRoomTypeViewModel.createNip17ChatRoom` already
makes for a robust group, and the reason step 1 costs almost no new code.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**Two tags are added to the ceremony proposal**, on `DkgRitualEvents.PROPOSAL`
only, and both are cosmetic in the strict sense that nothing acts on either.
`parent_group` is parsed by `acceptProposal` onto `DkgSession.parentChatRoomId` .
It authenticates nothing — anyone can claim any parent — and is read for exactly
two things: the ritual screen saying "a subgroup of Ekklesia" instead of "a shared
key ceremony", and a member finding their way back into a flow they closed the app
halfway through. The load-bearing claim is the certificate, three steps later, and
the code should say so where the column is declared.
`subject` is the fix for a gap this flow inherits and makes worse.
`NostrDao.getOrCreateNip17ChatRoom` already builds the receiving side's room with
`subject = decryptedGiftWrapPayload.parseSubject()` , and
`ChillDkgRitualManager.broadcast` writes no subject tag — so today a member
selected for a ceremony watches an **unnamed ** chat room appear on their device
with a key ceremony already running in it. That is survivable when the user just
agreed to make a group with those people; it is not when the room is a means to an
end they were not consulted about. The proposal carries the subgroup's name, the
receiving room gets it for free through a reader that already exists, and the
change is one tag on one broadcast.
It is worth fixing for the existing robust-group flow in the same commit, since
`SelectChatRoomTypeViewModel` passes a `subject` to `createNip17ChatRoom` that only
ever reaches the creator's own device.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
### The collision this buys, and it is real
A NIP-17 room's id is a pure function of its members, so **one admin set gets one
ceremony room, forever**. Two consequences, and both need a guard rather than a
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.
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>
2026-09-09 00:38:49 +02:00
- Selecting * every * member of the parent lands in the parent's own ceremony room,
which is the room over all its members.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
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>
2026-09-09 00:38:49 +02:00
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 ).
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
### Why not the parent's Marmot room
The obvious alternative is to hold all three steps in the parent room: no sibling
room, no member-derived id, and the collision above disappears. It is a better
design and it is not available yet. What follows is why, because "we did not"
without "and here is the price" is how a decision gets re-litigated every six
months.
**It is one decision, not three.** The certificate already runs in the parent
room and is the only one of the three free to run anywhere. The key state is not:
```kotlin
// GroupKeyStateManager.propose
check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId)
// FrostSigningManager.signingPath, the one case that cannot be self-checked
if (localChatRoom.chatRoom.mlsGroupState == null && key.chatRoomId == chatRoomId) {
return SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
}
```
The child's room does not exist, so `chatRoomId == signingRoomId` cannot hold and
the key state has to be signed **where its ceremony ran ** . Both guards say so
independently. So the ceremony and the key state move together or not at all, and
the question is only ever "which transport does the child's ChillDKG ride".
**What moving it would cost, mechanically.** Three things, all enumerable:
1. `ChillDkgRitualManager.broadcast` writes only a `GiftWrapPayload` ; it needs the
two-transport shape `FrostSigningManager.broadcast` already has, plus a Marmot
inbound arm in `NostrDao` beside the one dispatching FROST signing at kind
30320-30325. The p-tags stay on **both ** transports, unlike FROST's — for a
ceremony the p-tag set * is * the participant set every device derives `n` from,
and inside MLS encryption naming the participants leaks nothing.
2. `DkgSession.chatRoomId` stops identifying a ceremony. Three queries key on it
and all three break: `proposeRitual` 's one-ritual-at-a-time guard would return
the parent's * own * completed session and refuse to open a subgroup ceremony at
all; `observeLatestSessionForChatRoom` would show the child's ceremony on the
parent's shared-key screen; and `completedKey` 's third fallback would resolve
the **child's ** key for the parent room, for any parent member holding no key
state row — which is every member welcomed after the parent's own ceremony.
Fixable with a `subjectChatRoomId` column and three scoped queries, but that
third one is a load-bearing fallback and the failure is silent.
3. `signingPath` 's special case is gated on `mlsGroupState == null` and would have
to admit a Marmot room. Still device-local — both inputs are read from this
device's own database, so a proposer still chooses nothing — but it widens the
one function whose contract is that the path can never come off a proposal.
**Why not, even after all that.** `docs/mls-skipped-keys.md` names
`ChillDkgRitualManager.proposeRitual` in its table of reliable triggers —
"proposal, then the host key" — and today that costs nothing, because the ritual
runs on gift wraps and the bug is in MLS. Moving it onto group events puts a
ceremony on the one transport where a message arriving a moment late is dropped
for good.
A DKG is the worst possible payload for that. It **cannot finish until every
participant takes part**, so one lost round-1 message stalls it permanently for
everybody, and the ritual's own resume machinery cannot help: relays redeliver
gift wraps and `replayStoredMessages` re-feeds the backlog, while a group event
whose inner event never materialised leaves a stored `MarmotGroupEvent` row and
nothing to replay. FROST signing survives the same transport only because it
needs `t` of `n` and routes around the signer whose nonce was lost.
`MlsGroupCache` covers the burst-in-one-sync case and nothing else: a restart
loses the skipped keys, and any other writer in the room — a chat message, a
member being added — invalidates the cache mid-ceremony.
**So: revisit when the quartz fix lands.** Once `saveState` /`restore` carry the
skipped keys, the parent room is strictly the better home for all three steps,
the collision above stops being a refusal and becomes a non-issue, and
`DkgSession.parentChatRoomId` stops being needed at all. Until then the child's
ceremony rides the transport that tolerates reordering, and pays for it with one
sibling room and one refusal.
## Phase 5 — the manager, and the four steps in order
New `managers/SubgroupManager.kt` , holding the orchestration that does not belong
to any one of the three existing managers. It calls them; it does not
reimplement them.
```kotlin
object SubgroupManager {
/** The NIP-17 room a ceremony for these admins would run in. Pure. */
fun ceremonyRoomIdFor(adminPublicKeys: Set<HexKey>, coordinator: HexKey): String
/** Step 1: stand up the ceremony room and open the ritual in it. */
suspend fun openCeremony(...): DkgSession
/** Step 2: ask the parent's quorum to certify the child. Runs in the parent room. */
suspend fun proposeBirthCertificate(
database: MantraDatabase,
parentRoom: LocalChatRoom,
userPublicKey: HexKey,
key: DkgSession,
adminPublicKeys: List<HexKey>,
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
name: String,
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
): FrostSigningSession
/** The newest certificate for this child that actually certifies it, or null. */
suspend fun certificateFor(
database: MantraDatabase,
subgroupChatRoomId: String,
parentChatRoomId: String
): GroupSignedEvent?
/** Step 3: ask the child's quorum to sign its key state, carrying the certificate. */
suspend fun proposeSubgroupKeyState(...): FrostSigningSession
/** Everything the parent has certified, whether or not the room exists here. */
suspend fun subgroupsOf(database: MantraDatabase, parentChatRoomId: String): List<Subgroup>
/** Files a certificate that arrived, or drops it and says why. */
suspend fun record(database: MantraDatabase, chatRoomId: String, innerEvent: Event): GroupSignedEvent?
}
```
`proposeBirthCertificate` is one `FrostSigningManager.proposeSigning` call in the
parent room with `key = null` — the parent's key is resolved from the parent room
the way every other signature in that room is, and a caller naming a ceremony
here would be a caller choosing what the group signs as. A session of one, never
batched, for the same reason `GroupKeyStateManager.propose` gives: a batch is only
as available as its worst item, and this one is a precondition of everything
after it.
`proposeSubgroupKeyState` is `GroupKeyStateManager.propose` with the parentage
filled in, and it must refuse to run without a certificate that passes
`certifies` . Proposing a state the coordinator's own device would drop is not a
mistake worth letting the group discover by signing it.
`subgroupsOf` reads the parent's `GroupSignedEvent` rows of kind 30329, filters
them through `certifies` , and joins each to the local `ChatRoom` if there is one.
No table of subgroups: the certificates * are * the record, they are the group's own
signed statement, and every device in the parent room already has them —
`FrostSigningManager.complete` files a `GroupSignedEvent` on every device that
followed the session, not only on the signers'.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**Two certificates for one child is a normal outcome, not a conflict.** Two parent
admins can press the button on the same admin set: the second lands in the same
NIP-17 ceremony room and gets the first's ceremony back, but both may go on to
propose a certificate, and both sessions can complete. `GroupSignedEvent` is keyed
on the event id, so the rows coexist. `certificateFor` therefore takes the
**newest** of those that pass `certifies` , the way `GroupKeyStateManager.signedAmong`
already picks the newest state — and because both certificates say the same true
thing about the same child, which one wins does not matter. The `d` tag makes them
replacements of each other rather than an accumulation, which is what it is for.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
### Two dispatch arms, both easy to forget
`ChatMessage.applyInnerEvent` needs a **30329 ** arm. Without one the certificate
falls to `else ->` and every parent member gets a raw-JSON chat bubble; that arm
is the failure mode `docs/member-chronicle.md` names for an old build meeting a
new kind, and here it is avoidable. The arm delegates to `SubgroupManager.record` ,
which verifies against the room it arrived in — the event reaches this path both
from `FrostSigningManager.applySignedEvent` , where it is already checked, and
from an arriving inner event, where it is not, and the arm cannot tell which.
Unlike the key-state arm it **does ** write a chat line. A key state is standing
state whose session already wrote the transcript; a subgroup being born is
something that happened, and it happened on behalf of parent members who are not
in the child and will otherwise never be told.
`ProposedEvent.summaryOf` needs a 30329 arm too, or the parent's admins approve
"Event of kind 30329" with the JSON underneath. It should read as the members and
the path — the id is the thing being decided and is shown by the screen anyway.
### Not chroniclable
30329 does **not ** join `ChronicleEvent.CHRONICLABLE_KINDS` , for the reason that
list gives for excluding `GroupKeyStateEvent` : verification admits an event on the
strength of the group's signature, so any chroniclable kind is replayable by any
member forever. A member added to the parent after a subgroup was made therefore
sees an empty subgroup list — named under
[what this does not do ](#what-this-does-not-do ), with the argument for changing
it later.
## Phase 6 — creating the room, once, for both flows
`DkgRitualViewModel.createAdminGroup` is 120 lines of Marmot room creation living
in a ViewModel: resolve every member's key package or refuse, build
`MarmotGroupData` with the admin list baked into epoch 0, `MlsGroup.create` ,
`getOrCreateChatRoom` , adopt the key state, add the members. The subgroup needs
all of it and differs in four values.
So it moves, unchanged in behaviour, to `managers/MarmotGroupCreation.kt` :
```kotlin
suspend fun create(
database: MantraDatabase,
chatRepository: ChatRepository,
groupId: String,
name: String,
purpose: String,
adminPublicKeys: Set<HexKey>,
userPublicKey: HexKey,
keyPair: KeyPair,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
parentChatRoomId: String? = null
): Outcome // Created(room, notAdded) | BlockedOn(missingKeyPackages) | Existing(room) | Failed(reason)
```
Both call sites then read as four values and a result. The rules that are already
right stay right for the subgroup for free, and they are the ones worth not
re-deriving:
- **Every key package before anything exists.** The id is derived, so there is
exactly one room per key at this path; a half-created one occupies that address
permanently and there is no second id to retry with. Better to create nothing
and name who is missing.
- **`adminPubkeys` baked into epoch 0**, so a member welcomed later gets a
populated group rather than chasing a bootstrap commit that predates them.
- **`adopt` before the members are added**, because filing the key state is local
and certain and adding members is neither.
`parentChatRoomId` is the one genuinely new parameter, written onto the
`ChatRoom` row. The subgroup's description carries the path through
`SharedKeyDerivation.describe` , exactly as the admin room's does — MIP-01 has no
field for either, and the path is what rebuilds the `TweakCache` .
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
### The description carries the parent too, as a hint
`describe` gains a second optional line, beside the path:
```
Admins of Ekklesia's translation work.
Shared key path: m/9420/0/0
Parent group: 4f2b…
```
It is there for one member and one problem: somebody welcomed into the subgroup
*after* it was founded. The verified parent link lives on `GroupKeyState` , filed
by `adopt` from the signed key state — which only ceremony participants hold — and
certificates are not chroniclable, so a later joiner has a room, no key state, and
no way to learn the room is anybody's child. `MarmotGroupData` is the one thing
that does reach them: it rides in the Welcome and is baked into the epoch-0 group
context, so every member gets it however late they arrive.
**It is a hint and must be rendered as one.** Group data is written by the room's
creator and agreed by MLS, not by the parent — so this line says "this room claims
Ekklesia as its parent", which is a strictly weaker statement than the certificate
makes. The rule for the UI is the one Phase 2's table states: where a verified
`GroupKeyState.parentChatRoomId` exists, it wins and the hint is never consulted;
where it does not, the hint may be shown as unconfirmed and must not be written
into the verified column. The upgrade path is the certificate itself — see
[what this does not do ](#what-this-does-not-do ).
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## Phase 7 — the UI
Four pieces, three of them small. Every one of them is subject to `./gradlew
:composeApp:m3Audit`: strings in ` strings.xml` in sentence case, spacing from
`MaterialTheme.spacing` , colour from a role, `minimumInteractiveComponentSize`
on anything clickable that is not an `IconButton` , and a decided
`contentDescription` .
**A subgroups section on `ChatRoomDetailScreen` **, between members and the
danger zone, listing `SubgroupManager.subgroupsOf` with one of three supporting
lines per row — * certified, not yet created * / * created, you are not a member * /
the room's own subject if this device is in it — and an "add subgroup" `TextButton`
above them in the shape the existing "invite new member" one has. The row's tap
target navigates into the child's room if this device has it and to the
certificate's `NostrEventDetailRoute` if it does not.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**The row's title comes from the room where there is one, and only otherwise from
the certificate.** That ordering is the frozen-roster rule from
[the consent section ](#informed-consent-and-what-the-parents-admins-are-actually-signing )
applied at the only place it is visible: the certificate's `name` and `p` tags are
what the parent approved at founding, and a subgroup that has since been renamed
or has added members would otherwise be listed under a name nobody uses and a
membership nobody has. Where this device holds the child's room, the room is the
live answer. Where it does not, the certificate is all there is, and the row says
so by carrying its supporting line rather than pretending to be current.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
Offered only where it can work: this device is an admin of the parent
(`Participant.adminAt != null` ), the parent can sign
(`FrostSigningManager.canSign` ), and the parent is a Marmot room. Hidden, not
disabled, in the same way the shared-key entry is hidden on an MLS room.
**A parent row on the child's detail screen**, read from the verified
`GroupKeyState.parentChatRoomId` , sitting directly under the signing-key card —
where a member looks to find out what the room is.
**`SelectSubgroupAdminsScreen` ** and its route and ViewModel, modelled on
`SelectChatRoomMembersScreen` . The pool is the parent's own participants, admins
and non-admins alike, with the parent's admins marked rather than filtered — the
point of a subgroup is that it can be run by people the parent does not let run
the parent.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**A name field at the top**, because nothing else in the flow can supply one.
`createAdminGroup` synthesises `"${parent.subject} (#admins)"` and gets away with
it — a group has exactly one admin room and the name states a relationship rather
than a choice — but a group can have many subgroups and "Ekklesia (#subgroup )"
names none of them. The name is required, travels on the route to Phase 6's
`MarmotGroupData` , and is copied into the birth certificate so the parent's admins
approve something legible.
### Key packages, checked here rather than discovered at step 4
The single most likely way this flow fails, and the cheapest to prevent.
A key package is **one-time-use ** : `NostrDao` marks the bundle `consumed = true`
as the device processes its own Welcome, and `MarmotKeyPackageBundleDao` only ever
hands back one with `consumed = false AND rotated = false` . Every group a member
joins burns one. So a member who is in the parent room and has not published a
fresh key package since has none left, and Phase 6 will refuse to create the
subgroup for exactly the right reason — after a ChillDKG, a parent quorum and a
child quorum have all completed, each of which required every selected admin to
show up and approve. Three multi-party ceremonies, then a dead end, and no second
room id to retry against.
So availability is a property of the **picker ** , not of the create step:
- Prefetch every candidate's key package as the screen opens, the way
`SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` already
does for group creation — by the time somebody has finished ticking names and
set a threshold, the relay round trip is usually done.
- Mark a member with none, and refuse to select them, with the reason and the
remedy in the same line: they need to publish a new key package. Anything vaguer
makes it the coordinator's problem to diagnose.
- Re-check at confirm, because the screen may have been open a while.
Phase 6's refusal stays exactly as it is. It is now a **backstop ** rather than the
first line of defence — a member's package can be consumed by some other group
between the picker and the create step — and a backstop is worth having precisely
because the address it protects is permanent.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
**Three admins in total, the coordinator included**, so the picker asks for at
least **two ** others. Three is `ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE` , and it
is the same floor for the same reason it states: below three a quorum is not a
check, because two admins means every decision needs both of them and one means
the coordinator is deciding alone. The coordinator counts because they are a
ChillDKG participant by construction — they hold a share whether or not anybody
ticked their name — so the screen shows their own row selected and locked rather
than leaving them out of the count the confirm button is testing.
**The threshold is the coordinator's to set, on this screen, and only here.**
A stepper over `ChatRoomType.quorumRange(adminCount)` — `2..3` for the smallest
subgroup — starting at `ChatRoomType.defaultQuorum(adminCount)` , which is `2` of
`3` . It has to be settled before anything is published, and that is a protocol
fact rather than a layout preference: ChillDKG hashes the threshold and the host
keys into the session identity, so `t` is fixed the moment the proposal goes out
and a group that disagrees about it gets no key at all rather than a weak one.
There is no later screen where it could be changed, and the plan should not
pretend otherwise.
It is also the one value the coordinator picks * for other people * . Consent is not
lost by that: the threshold rides on the proposal in `DkgThresholdTag` , every
invitee's `acceptProposal` re-checks it against `quorumRange` and drops a
proposal outside it, and the `HOST_KEY` gate is where each of them agrees to the
`t` -of-`n` they can now see. Joining is the agreement; the coordinator only
writes down what is being joined.
Confirm stays disabled below two selections, and below a threshold outside the
range, with the reason stated rather than left to be guessed.
**`DkgRitualRoute` gains an optional `parentChatRoomId` **, and the ritual screen
grows one rung. The alternative — a parallel subgroup screen — duplicates a
ladder, a threshold picker, three approval gates and a key-state rung in order to
insert one step, and the two copies would drift within a release. The rung is
rendered only when a parent is present, and it is where the coordinator proposes
the certificate and watches the parent's quorum answer.
The approvals themselves need **no new UI ** . The certificate is a
`FrostSigningEvents.PROPOSAL` in the parent room and the key state one in the
ceremony room; `ProposalListScreen` and `FrostSigningScreen` already show and
approve both, on both transports.
## Phase 8 — the refusals
A subgroup is expensive and its room id is permanent, so every one of these is
checked before the button is offered * and * again in the manager. The UI check is
what keeps a user out of a dead end; the manager check is what keeps a bug out of
a room nobody can replace.
| refused | because |
|---|---|
| fewer than three admins in total — the coordinator plus two | below three a quorum is not a check — the same floor `ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE` states |
| 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 |
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
| 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 |
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>
2026-09-09 00:38:49 +02:00
| ~~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 |
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
| 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 |
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>
2026-09-09 00:38:49 +02:00
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.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
## Phase 9 — the tests that prove it
The pure ones carry the weight, because every check that matters is pure:
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
- `SubgroupBirthCertificateEventTest` (commonTest) — Phase 1's seven cases,
including the one that must **pass ** : a certificate whose name and admin set no
longer match the room's, which fails the day somebody adds the roster check
Phase 1 forbids.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
- `GroupKeyStateTest` (commonTest) — Phase 3's five `stateFrom` cases.
- `SubgroupGuardsTest` (commonTest) — Phase 8's table, one case each, against
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
the pure predicates rather than the ViewModel. The key-package row is the one
worth writing first: it is the guard that exists to stop three ceremonies being
spent on a room that cannot be created.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
Two that need a database, in jvmTest, where `secp256k1` loads and Room runs:
- `SignedGroupKeyStateTest` extension — a real ChillDKG key, a real parent key, a
real certificate signed by the parent, a real child key state carrying it, and
the assertion that `stateFrom` accepts it and rejects each single-field
mutation of it.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
- `SubgroupDaoJvmTest` — schema 17 round-trips the four columns, and
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
`subgroupsOf` returns a certified child whose room does not exist locally.
One end-to-end, and it is the one that would have caught the collision in Phase
4: two ceremonies proposed over the same admin set land in the same room, and the
second is refused rather than silently returning the first's key.
## Rollout
Nothing here changes an existing flow's behaviour. The two shared pieces are the
risk, and both are additive:
- `GroupKeyStateEvent.assembleTags` emits the new tags only when a parentage is
passed, so every state signed before this ships verifies exactly as it did.
- `GroupKeyStateManager.stateFrom` 's new checks fire only on a state carrying one
of the new tags. A state with neither takes the path it takes today.
An older build meeting a subgroup's key state ignores two unknown tags and files
the state as an ordinary one — it loses the parent link and keeps a correct
answer to what the room signs with, which is the right way round. An older build
meeting a **certificate ** has no 30329 arm and writes a raw-JSON chat bubble in
the parent room, once per subgroup. That is cosmetic, it is the same thing
`docs/member-chronicle.md` reports for chronicle pages, and it is the reason to
ship Phase 5's dispatch arm before anybody creates a subgroup rather than after.
Phases 1-3 can ship dark: they add a kind nothing produces and checks nothing
triggers. Phases 4-6 are inert without Phase 7's entry point. So the flag, if one
is wanted, is the button.
## What this does not do
**A subgroup cannot sign for its parent, and the parent cannot sign for it.**
Two independent keys, two independent quorums. The certificate is a claim about
lineage, not a delegation of authority, and nothing in this design lets one group
act as the other. If delegation is ever wanted it is a different mechanism and it
should not be built on top of this one silently.
docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
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>
2026-09-08 23:04:32 +02:00
**A member added to the parent later sees no subgroups, and a member added to the
subgroup later cannot verify its parent.** One cause, two faces, and both are the
chronicle allowlist. Certificates are not chroniclable, for the reason Phase 5
gives, so a member welcomed into the * parent * after a subgroup was made holds no
certificate and gets an empty list. A member welcomed into the * child * holds no
key state either — `adopt` files one only from the signed event, which only
ceremony participants have — so their verified parent link is null, and the
description hint from Phase 6 is all they get: a claim by the room's creator
rather than a statement by the parent.
Chronicling the certificate fixes both at once, and the argument for admitting it
is better than it is for a key state: a certificate says "P certified C", a fixed
historical fact, whereas a key state decides what a live room signs with, so a
replayed certificate changes nothing a fresh one would not. It still deserves its
own change — its own idempotency test, its own apply-order slot, and a decision
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.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
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>
2026-09-09 00:38:49 +02:00
**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.
docs: plan subgroups, and phase the four ceremonies a group needs to make one
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>
2026-09-08 22:53:31 +02:00
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,
and there is no member-derived id left to collide.
**Every selected admin has to show up, twice.** A ChillDKG cannot finish without
all `n` , and the key state then needs a quorum of them. A member who never
approves stalls the subgroup at step 1 indefinitely, and the only signal is the
ritual screen naming who it is waiting on. This is inherited from
`docs/shared-key-ceremony.md` and is the single biggest practical cost of a
subgroup.
**A subgroup of a subgroup is untested.** Nothing forbids it — the child is an
ordinary robust group with a key of its own, so it can certify a child in turn —
and the chain is checkable link by link. But no phase here exercises depth 2, and
the parent row on the detail screen shows one level rather than a path.
**Removing a subgroup is not a thing.** There is no revocation, and a certificate
is a signature that exists forever once made. Deleting the child's room locally
leaves the parent's list showing a certified child nobody has.
## Appendix — what was considered and rejected
**A derived child key at `m/9420/1/0` , with no ceremony.** Free, instant, and
wrong: `docs/shared-key-derivation.md` states that anyone learning one derived
private key recovers the threshold key, and more to the point the child would be
administered by the parent's members with the parent's quorum. That is a channel,
not a subgroup.
**The certificate as a bare 64-byte signature plus a rebuild rule.** Smaller on
the wire, and it makes `GroupKeyStateManager.stateFrom` responsible for
reconstructing byte-for-byte the event the parent signed — kind, author, tags, and
`created_at` — from fields carried alongside. The first change to the
certificate's tags then invalidates every state already signed, silently, because
a rebuilt event that differs by one byte hashes to an id whose signature does not
verify and is indistinguishable from a forgery. Storing the whole event costs a
few hundred bytes inside an encryption and removes the class.
**A `Subgroup` table.** The certificates already are the record: signed, held by
every parent member, and verifiable without the table. A table would be a second
copy that can disagree with them, and the only thing it would add is flow state
for the coordinator — which `DkgSession.parentChatRoomId` covers with one column.
**Running the child's ChillDKG and key state inside the parent's Marmot room.**
The better design, deferred rather than rejected, and argued in full in
[Phase 4 ](#why-not-the-parents-marmot-room ). Short version: the two move
together, the mechanical cost is three enumerable changes, and the reason to wait
is `docs/mls-skipped-keys.md` — a ceremony needs every participant, and group
events lose one of any two that arrive back to back.
**A dedicated subgroup derivation path.** `m/9420/0/0` is reused, because the
path is walked from the * child's own * key and two different keys at the same path
derive two different rooms. A separate constant would say something true about
lineage in a place nothing reads for lineage.
**A parallel subgroup ritual screen.** Rejected in Phase 7: it duplicates four
pieces of a five-piece screen to insert one, and the copies drift.