Files
mantra-kmp/docs/subgroups-multiple-parents.md

804 lines
45 KiB
Markdown
Raw Normal View History

docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
# Adding a parent to a group that already exists
docs: record what the adoption plan built, and the eight places it chose differently The note shipped as research and is now five commits of code, so it says so, in the shape docs/subgroups.md uses: the phases kept as written because they are the reasoning, and a table of where the build disagreed with them. Eight entries, and two are worth more than the rest. **A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent** -- author, subject and named parent one room, the id deriving from the key, the signature verifying. The note never considered it. The self-parent guard began inside the offer branch, where it never ran, because the child branch catches such a certificate first and reads it as an ordinary child. A test caught it, and the gap predates this work: `subgroupsOf` would list the group as its own subgroup today. **The certificate's roster.** The note was careful that a founding certificate's name and `p` tags are the founding roster, frozen, and said nothing about adoption -- where there is no founding. The adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now, and the current roster is the honest value rather than a compromise. Still frozen the moment it is signed, still never read as live. The rest are smaller and all one shape, which is a note counting a thing and missing its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that travelled through four files to reach its only writer; `stateFrom` returning "a state and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that is also a plain data class, so the pure verifier builds one with no database near it. One thing the note asked for is **not** done and cannot be: the key-state summary saying which parents are being *dropped*. A summary is pure and a disavowal is a difference against the row this device holds, so it belongs to the screen. The rough-size table becomes estimated against actual. It guessed about two dozen files and it was 39, with the UI three times its estimate -- a picker needs a route, a screen, a view model and a state, which the note had as one line -- and the repositories half of it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files, 53 new cases. `docs/README.md` moves the note from research to a phased plan that has been built, and its one-line summary now ends on the self-certificate rather than on the consent gap, which is closed. Docs only -- no code changed, so there are no test counts of this commit's own. The five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:18:17 +02:00
How a group gains a second parent after it was founded.
**Built**, five phases, one commit each. The phases below 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 eight times worth reading:
| what the note said | what it turned out to be |
|---|---|
| the 30329 arm "gains a second branch" | a pure `SubgroupManager.classify` in front of it. The arm cannot tell its two callers apart and the decision wanted testing without a database, which `certifies` makes possible -- it checks a signature against a room id and needs no lookup |
| nothing about a group certifying itself | **a real gap, found by a test.** A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent. The self-parent guard began inside the offer branch, where it never ran: the child branch catches such a certificate first and reads it as an ordinary child, which would list a group as its own subgroup. It predates this work -- `subgroupsOf` would have shown it |
| drop `ChatRoom.parentChatRoomId` | and the `parentChatRoomId` parameter threaded through four files to reach the write that was its only writer. The note counted the column and missed the thread |
| `stateFrom` returns a state and a lineage | `LocalGroupKeyState`, a Room read model that is also a plain data class -- so the pure verifier builds one with no database near it, and `@Relation` serves the DAO |
| the certificate's roster is the founding roster, frozen | true, and silent about adoption, where there is no founding. The adopting group's admins are asked about a group that *exists*, so the roster and name are its **current** ones -- still frozen the moment they are signed, still never read as live |
| the summary says which parents are being dropped | not done, and it cannot be: a summary is pure and a disavowal is a difference against the row this device holds. It belongs to the screen, which has the room, and there is a comment where somebody will look |
| `EmptyState` takes a supporting line | it does not. The reason folded into the message, which is the whole content of that absence anyway |
| `inComparableGroups` moved to the key extensions | in the fork's tree, not this one, where it is still private to `ChatRoomDetailScreen`. A second copy to add three spaces to an id is not worth having |
Two things the note got right that are worth keeping if any of this is rewritten.
Emitting the parentage tags **pairwise** rather than as two runs, which is the one
decision here made entirely for builds that do not exist yet -- and which has a
test asserting the tag order, not just the round trip. And `replace` rewriting a
lineage rather than merging it, which is what makes a disavowal expressible at all
and is the reason that method is a transaction.
**Not reachable yet:** `proposeDisavowal` is built and tested and has no button.
Dropping a parent is a destructive-looking act that wants a confirmation step, and
it belongs in its own change.
Written against the code as it stands at `e15d31b8`.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
Read [subgroups.md](./subgroups.md) first. This extends it and contradicts it in
one place — the assumption, never stated because it never had to be, that a
lineage is a single link settled at founding.
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
Written against the code as it stands at `0be31803` — that is, **after**
`subgroups.md`'s Phase 10 moved the child's ceremony into the parent's Marmot room.
That move removed two of the costs an earlier draft of this note claimed adoption
would avoid and reshaped a third, so the case is made here on what is left. It also
makes founding with two parents *harder* — see the
[rejected alternatives](#considered-and-rejected).
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
## The short answer
**Adoption is two FROST sessions and no ceremony.**
```
1. birth certificate, in the new parent -> P2's quorum signs C
2. key state, in C itself -> C's quorum re-announces, carrying
every certificate it accepts
```
That is the whole flow. Compare it with founding, which is four steps:
| founding a subgroup | adopting an existing group |
|---|---|
| 1. ChillDKG among the child's admins | — the key already exists |
| 2. birth certificate, in the parent | **the same step, unchanged** |
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
| 3. key state, in the parent's room where the ceremony ran | **the same event, in the child's own Marmot room** |
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
| 4. create the Marmot room | — the room already exists |
Losing step 1 is the point. `subgroups.md` names it as the single biggest
practical cost of a subgroup — *"a ChillDKG cannot finish without all `n`… a
member who never approves stalls the subgroup at step 1 indefinitely"* — and it
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
is also the last thing standing between a lineage and being an ordinary edit.
Phase 10 has already paid down what it used to drag in: the NIP-17 sibling room and
the member-derived id are gone, and the collision guard is now
`DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather
than an address that could only be occupied once. So this note claims none of those as
savings — only the ceremony and the room creation, which is enough.
**The whole of `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`,
`SelectSubgroupAdminsScreen` and the ritual screen's rung is untouched by this
design** — and Phase 10 makes that a stronger statement than it was, because those
files have just absorbed a large change that adoption does not have to follow.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
Three things in the existing code make step 2 work as it stands:
- `GroupKeyStateManager.propose` already permits a room to propose its own key
state: `check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId)`,
and for an existing child the first branch holds. Nothing forbids
re-announcement today; it is simply never done.
- `FrostSigningManager.signingPath` resolves correctly for it. Its first candidate
is `GroupKeyStateManager.keyStateFor(chatRoomId)?.pathIndices()`, which for the
child is `m/9420/0/0`, and `marmotGroupId(K, path) == chatRoomId` self-checks.
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
The one un-self-checkable case is **not** reached. Phase 10 widened it from
`mlsGroupState == null` to `isCeremonyRoom && (mlsGroupState == null ||
isSubgroupCeremony)`, so it now admits the parent's room as well as a NIP-17 one —
and adoption enters neither arm, because the child's own room is neither the
ceremony's room nor its claimed parent.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
- `GroupKeyStateDao.replace` keeps the newest state per room by `announcedAt`, and
`announcedAt` comes off the signed event. So a re-announcement replaces the old
lineage cleanly, and an out-of-order arrival is dropped rather than applied.
## A parent link is two signatures, one from each side
Worth stating plainly, because the founding flow bundles them and so hides it.
The certificate is the **parent's** consent: P's quorum saying "C is our child".
The parentage tags on the key state are the **child's** consent: C's quorum saying
"we accept P". Neither alone is a link. A parent cannot adopt a group that has not
agreed, and a group cannot claim a parent that has not signed — which is exactly
why `GroupKeyStateManager.parentageOf` drops a state whose certificate does not
check out, and why `SubgroupManager.proposeBirthCertificate` runs in the parent's
room rather than the child's.
At founding the two are bundled because the key state has to be signed anyway: it
is the founding fact, and the parentage rides along. For an existing group there
is no founding fact left to settle, so **step 2 is a key state signed purely as a
carrier for the child's consent.** That is the one slightly odd thing about this
design and it is worth doing anyway — see [why not a separate adoption
kind](#considered-and-rejected).
Once that is seen, the whole thing collapses to one sentence:
> A group's lineage is a set of (parent, certificate) pairs carried on its key
> state. Adding a parent is: that parent certifies, then the child re-announces
> its key state with the pair added. Founding a subgroup is the case where the
> set has one member and the key state was being signed anyway.
## The invariant, and how it differs from founding-time multi-parent
If two parents certified a child *at founding*, both certificates would be signed
at the same moment and could name each other — so a reader could demand that every
certificate agree on the whole parent set, and a coordinator could not staple two
unrelated certificates into a joint child.
**That check is not available here, and cannot be made available.** P1 certified C
before P2 existed as a possibility. An earlier signature cannot name a later
co-parent, and asking P1 to re-certify every time a sibling appears makes every
adoption cost a quorum in every existing parent.
So the invariant has to be weaker, and the honest version is:
> **The child's key state is the sole authority on the lineage set. Each
> certificate is checked individually — this parent signed for this child — and
> the set is whatever the child's quorum most recently declared.**
Which means a parent gets a veto on being *added* and no veto on *who else is in
the set*. That is not a compromise; it is the correct answer:
- Nothing P1 signed becomes false. P1 said "C is our child". C being P2's child
as well does not contradict it.
- Exclusivity was never expressible and could not be enforced anyway. P2 can
certify C whether P1 likes it or not — a certificate is a unilateral statement
about a public room id. What P2 cannot do is make C *accept* it.
- **The child's consent is the whole gate**, and it should be, because the child
is the one that acquires an obligation.
`parentageOf` therefore grows from four checks to six, and the two new ones are
small:
1. **All or none**, per pair. (Today's rule.)
2. Each certificate parses. (Today's.)
3. Each `certifies(certificate, child, itsParent)`. **`certifies` does not
change** — it is six questions about one signer and one child and it stays
that, so `SubgroupBirthCertificateEventTest`'s existing seven cases keep
passing untouched.
4. Each certificate's `subgroup_key` is the state's own threshold key. (Today's.)
5. **No duplicate parents.** A set with a repeat in it is not a set.
6. Any failure drops the whole state. (Today's.)
There is no `co_parent` tag and no cross-certificate agreement check. Both were
needed only by the founding-time design, and both are gone.
### The footgun this creates
A key state is replaceable and the newest wins **wholesale**. So re-announcing to
add P2 must re-emit P1's pair as well, or P1 is silently dropped.
That is a bad API to leave open. `propose` should take the *resulting* set and
read the current one off the row, so that "add P2" is expressed as an addition
rather than as a full replacement a caller has to remember to assemble. The same
call then expresses removal, which is the next section.
## What this also unlocks
Two things fall out of the same mechanism, and neither needs extra work.
**Any existing robust group can become a subgroup.** Nothing in the two steps
requires the child to already have a parent. A top-level group with a key state
gets adopted exactly the same way, with the set going from empty to one. That is
arguably worth more than multi-parent itself, and it is the same code path.
**A child can drop a parent.** Re-announce with the pair removed. This partly
answers `subgroups.md`'s *"Removing a subgroup is not a thing"* — and only partly,
so the limit is worth being precise about:
- The certificate is forever. Nothing revokes a signature and nothing should
pretend to.
- But a link is two signatures, and the child's half is replaceable. So the child
can stop claiming a parent, and every device holding its key state will stop
believing the link.
- The parent's own list does **not** follow, because `SubgroupManager.subgroupsOf`
reads the parent's certificates, not the child's key state. P1 keeps listing C.
- That is fixable in the display without any new protocol: `Subgroup` already
carries a `keyState` field, populated where the device holds the child's state.
A parent device that also holds C's key state can see that C no longer names it
and say so. A parent device that does not hold it cannot, and must not guess.
## The wire
### The key state carries pairs, in matched order
No new tag names. `parent_group` and `birth_certificate` repeat:
```
["parent_group", <P1>]
["birth_certificate", <P1's certificate, as JSON>]
["parent_group", <P2>]
["birth_certificate", <P2's certificate, as JSON>]
```
`SubgroupParentTag.parse` and `SubgroupBirthCertificateTag.parse` already work
per-tag; the readers change from `firstNotNullOfOrNull` to `mapNotNull`, and
`assembleTags` takes a list instead of a nullable single. That is the entire wire
change.
**The order is load-bearing, and only for old builds.**
`GroupKeyStateEvent.parseParentChatRoomId` and `parseBirthCertificate` take the
*first* of each, so a build predating this pairs `parent_group[0]` with
`birth_certificate[0]` and runs `certifies` on them:
- emitted **pairwise** — P1, P1's cert, P2, P2's cert — it pairs P1 with P1's
certificate, it verifies, and the device files a true but partial lineage.
Correct, just less.
- emitted **grouped** — both parents, then both certificates — it is coherent only
while both lists happen to be in the same order, and silently wrong the moment
they are not. Then `certifies` fails and the old build **drops the whole state**,
losing the row that says which ceremony's share signs for that room. Recoverable
through `completedKey`'s rederivation fallback, and only for default-path rooms.
So: emit pairwise, sorted by parent id so every device produces the same bytes.
Free, and the difference between graceful degradation and an old device quietly
losing its key state.
### The certificate does not change at all
Kind 30329 keeps its tags, its `certifies`, and its meaning. A certificate signed
for a founding subgroup and one signed for an adoption are the same event — which
is right, because they say the same thing.
## The schema
Much smaller than the founding-time version, because `DkgSession` is not involved.
**`GroupKeyState`: one-to-many.** The two columns `parentChatRoomId` and
`birthCertificateJson` become a table:
```kotlin
@Entity(primaryKeys = ["chatRoomId", "parentChatRoomId"], foreignKeys = [
ForeignKey(entity = GroupKeyState::class, parentColumns = ["chatRoomId"],
childColumns = ["chatRoomId"], onDelete = ForeignKey.CASCADE)
])
data class GroupKeyStateParent(
val chatRoomId: String,
val parentChatRoomId: HexKey,
val birthCertificateJson: String,
)
```
The composite primary key *is* check (5) — one certificate per (child, parent),
enforced by the store rather than by a reader remembering. The cascade is onto
`GroupKeyState`, never onto `ChatRoom`, so `subgroups.md`'s argument against a
self-referential cascade stands untouched: the parent side stays a plain column
that routinely names a room this device does not have.
The cost is that filing a state stops being one write. `GroupKeyStateDao.replace`
has newest-wins logic and would have to become a `@Transaction` that rewrites the
parentage rows with the state, or a dropped parent survives the re-announcement
that dropped it — which is exactly the operation this design adds.
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
**`DkgSession`: untouched.** Adoption opens no ceremony, so it needs no session, no
discriminator and no migration there. Phase 10 has just added two columns to that
table — `participantPublicKeys`, a sorted comma-joined set matched whole by
`getLatestSubgroupSessionFor`, and `subject` — which is worth knowing for two
reasons: it is the precedent for the canonical-string shape, and it is the change
adoption is glad not to be entangled with.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
**`ChatRoom.parentChatRoomId`: drop it.** It is write-only in production (see
[found while reading](#found-while-reading)), and leaving it holding one of
several parents makes it a cache that is wrong rather than merely unused.
docs: stop the adoption note naming a schema version, since two landed under it Seventy-one commits of `mantra` merged in -- npub and nsec sign-in, several profiles on one device, three pulls from the Curated fork, a profile preview and a FAB sweep -- and none of it touches subgroups. `docs/subgroups.md`, `SubgroupManager`, `GroupKeyStateManager`, `GroupKeyStateEvent`, `nostr/subgroup/`, `GroupKeyState` and `ProposedEvent` are byte-identical, so the design stands as written. The only `FrostSigningManager` change is a comment, recording that putting a signed event on relays is now a member's separate act from `BroadcastGroupSignedEventScreen`. **The note said schema 19 -> 20, and 20 was taken while it sat there.** 19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`; 20 is the several-profiles pull's, putting an owning identity on the queues. This is the second time the number has moved under a note that is not being built yet -- it was 18 -> 19 when first written three weeks ago -- which is twice too often for a figure nothing depends on. So it now says to read the version off `MantraDatabase` at build time, and the test line that named one does not. Everything else re-checked against the merged tree and unchanged: the four parent-indexed DAO reads are still test-only, `ChatRoom.parentChatRoomId` still has no production reader, `describe(purpose, path)` still takes no parent, and `GroupSignedEvent` still declares its foreign key onto `ChatRoom` -- the last of which is load-bearing for this design, since it is what forbids a child device storing a certificate signed by a group it is not in, and so forces the certificate to ride inside the key state. `docs/README.md` merged without a conflict: the row for this note sits where it was put, under the subgroups row, and the seven rows the sign-in and pull notes added append below it. Merged through the app's host sync rather than `git merge`, because the merge brings in a `.gitmodules` line -- `branch = master` on the lightning-kmp-app submodule -- which the sandbox is not meant to write. Docs only -- no code changed by this commit, so there are no test counts to report. The merged changes have not been built here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:10:16 +02:00
Schema **20 → 21**, and the number moves under this note faster than the design does:
19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`, and 20 was the
several-profiles pull's, putting an owning identity on the queues. So take the version
from `MantraDatabase` when this is built rather than from here. One new table, one
dropped column, and the existing rows carried across as a single parentage row each.
Not an `AutoMigration`; schema 9→10 already has the shape to copy.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
### The zero-migration alternative, and why not
The parentage columns are already a *cache*: `stateFrom` derives them from the
signed 30326 event, and `signedStateFor` can re-read the newest such event for any
room at any time. So the lineage could be a derived read — parse the signed event,
run the checks, return the set — with no schema change whatsoever.
Worth knowing it is available, and worth not taking. `signingPath` reads the
`GroupKeyState` row on **every signature**, so the row stays either way; a design
where the path comes off the row and the lineage comes off the event is two
sources for one statement, which is the thing `subgroups.md` spends its appendix
refusing. Cache the whole state or cache none of it.
## Who can actually do this
The one requirement that does not soften, and it should be stated before the UI is
designed around it.
**Step 1** — proposing the certificate in P2 — needs a P2 admin holding a share of
P2's key, *who also knows the child's threshold key*. The certificate's tags carry
`subgroup_key`, and without it `certifies` fails at question 4. The child's
threshold key lives on the child's `GroupKeyState`, which only the child's members
hold. So the proposer is in both groups.
**Step 2** — proposing the key state in C — needs a member of C holding a share of
C's key, *who has P2's certificate*. Certificates are filed as `GroupSignedEvent`
rows by `recordSignedEvents`, keyed on the session's room, so only P2's members
have one. So this proposer is also in both groups.
They need not be the same person, and their capabilities differ — step 1 needs
admin rights in P2, step 2 needs a share of C's key. But **adoption requires at
least one person in both groups**, and there is no way around it: the certificate
has to cross a boundary that nothing in the app crosses.
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
### One human, two profiles, and an unscoped read
Since several profiles landed, "one person in both groups" has a second reading, and
it is the one that will happen first: the same human as a P2 admin under one profile
and a holder of C's share under another.
That satisfies both steps and needs no single key in both groups, which is a real
loosening. The mechanism is the part to be careful about. `ChatRoom` is keyed by **the
group id alone** — `679d5249`'s message says so, and the MLS state, the DKG sessions
and the FROST shares all hang off the room — so the two rooms are two rows with
different ids and different `userPublicKey` holders in **one** database. P2's
certificate is a `GroupSignedEvent` hanging off P2's room, and
`groupSignedEventDao().getById(id)` is not scoped by identity, so the other profile's
code can read it.
So it works, and it works by reading across a boundary the app has already had to
defend once: `679d5249` exists because the Welcome branch could not tell a redelivered
Welcome from one for a room another profile holds, and told nobody.
**And the key check this design leans on does not draw that boundary either.**
`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` is
`WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL` with no identity in
it, and `getLatestOwnSessionForChatRoom` is scoped only by room and
`parentChatRoomId IS NULL`. So `FrostSigningManager.canSign` answers a **device-wide**
question — this device holds a share — where every sentence in this note that says
"this device holds no share of P2's key" means the profile in the chair.
That is pre-existing and device-local: both profiles' shares are already on one disk,
owned by one person, so nothing crosses a user boundary. What it costs is profile
isolation, which is the point of having profiles at all — and it is why **a step-2
proposer must take the certificate from a source scoped to the profile that is
acting**, rather than from whatever the database happens to hold. Otherwise adoption
is a feature that silently works better when you own both sides, which is the shape of
a leak rather than of a convenience. Worth its own look, not this note's to fix.
### Still weaker than founding-time multi-parent
Where one coordinator had to administer both parents *and* run a ceremony. And it is a
realistic requirement — a group being adopted by another group almost certainly has
someone
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
in both.
What would remove it is a cross-group request: a way for C to publish "we would
like P2 to adopt us, here is our key" and for P2's admins to act on it. That is a
real feature with its own consent questions, and it should not be smuggled in as
a helper.
### The certificate rides on the key state, and needs no storage of its own
The obvious-looking alternative — file P2's certificate on the child's devices
when it arrives — is closed, and the closure is load-bearing:
`GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with
no P2 room row **cannot** store a P2-signed event at all.
It does not need to. `GroupKeyStateManager.propose` takes the certificate as an
`Event`, not as a row, and once the child's quorum signs the re-announced state
the certificate travels inside it — verified standalone by `stateFrom` on every
child device, exactly as at founding. The proposer needs the event; nobody else
does.
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
### The one channel that must not be used, and it is already built
There is now a path that would carry a certificate out of the encryption, and it is
dormant rather than absent. `BroadcastGroupSignedEventViewModel` sends one
group-signed event to relays a member picks, and its own comment states the property
that makes it dangerous here: *"the signature is the group's, so any member holding
the event can send it and no share of the key is needed."* It is **not** restricted by
kind — "the seed is the same for every event of every kind" — and nothing opens it
today only because the rows that carried the button went with the curated lists.
`BroadcastGroupSignedEventRoute` is still registered and `BroadcastButton` still
built, so, in that file's words, wiring a button in is all it takes.
A certificate is the worst event in the app to send. It carries the parent-child
lineage, the child's threshold key, and the founding admin roster in its `p` tags —
a membership list — and `subgroups.md` places kind 30329 "in the private 303xx range
with everything else that never leaves an encryption".
`SubgroupBirthCertificateEvent`'s own comment leans on that: because it only ever
exists inside an encryption a relay cannot open, "the addressable semantics of the
3xxxx range never fire". Published, they fire — it is `d`-tagged on the child's room
id, so relays would treat certificates as replacements of one another, which is a
thing this design settles locally and on purpose.
**So this design carries a negative rule: the subgroups list and the parent cards must
never grow a broadcast button**, and a certificate must not be reachable from whatever
screen a future "broadcast this" sweep walks. It is also why relays are not the answer
to the overlap requirement above, tempting as it looks: a P2 member could publish the
certificate and a C member could fetch and verify it — `certifies` needs no lookup —
but the price is publishing the lineage and the roster to anyone, to save one person
being in two groups.
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
## Choosing the parent
Worth its own section, because "pick a group" is not what the picker can offer and
designing the screen around the wrong list would be the easy mistake.
### The list is the selector's own rooms, and cannot be anything else
A certificate is proposed **inside** the adopting group, by
`FrostSigningManager.proposeSigning` in that room. So the candidate list is not "the
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
groups that exist" or "the groups C's admins can name" — it is the rooms **the active
profile** can act in. Not the device's: `getChatRoomListByUserPublicKey` is keyed by
identity, and since several profiles landed a device can hold rooms it must not offer
here, because the key that would have to sign is not the one in the chair.
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
```
getChatRoomListByUserPublicKey(user)
filter { it is a Marmot room whose MarmotGroupData reads }
filter { adminPubkeys.contains(userPublicKey) }
filter { FrostSigningManager.canSign(database, it.id) }
filter { it.id != C }
filter { it.id !in C's current parents }
filter { it.id is not a descendant of C }
```
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
The first three filters are `SubgroupManager.refuseSubgroup`'s existing checks with
the target moved from "the parent of a new subgroup" to "the adopting group", and the
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for
the reason that function already gives: the flag is written only by
`processGroupMembershipChanges` on an arriving commit, so it is null for everybody on
a room this device created.
**A group cannot be offered as a parent by somebody who is not in it.** That is the
overlap requirement from [who can actually do this](#who-can-actually-do-this) showing
up as a list length. If nobody in C administers P2, P2 simply does not appear, and the
remedy is a person rather than a button.
### Cost, and where it goes
`toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`,
once per candidate room. `canSign` is cheap for exactly the rooms that pass —
`completedKey`'s first branch is a key-state lookup and a session lookup by id, and
its expensive fallback (`getKeyHoldingSessions` with a `marmotGroupId` derivation per
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
session) only fires for rooms with no key state, which fail the filter anyway. Its last
fallback is now `getLatestOwnSessionForChatRoom` — the same query scoped to
`parentChatRoomId IS NULL`, so that a parent hosting its child's ceremony cannot
resolve the child's key as its own.
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
So the filter is one MLS decode per room and two indexed reads. Fine at twenty rooms
and worth building the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization`
already builds its prefetch: off the main thread, filling in as it resolves, rather
than blocking the screen on the whole list.
### The selection is an offer, not a decision
The ordering is forced — the key state carries the certificate, so the certificate is
signed first — and that has a consequence worth stating in the UI's own words:
> **P2's quorum commits first. C's quorum decides second.**
Whoever picks P2 is not choosing on C's behalf. They are asking P2 to make an offer,
and C's admins answer it at step 2 by signing the re-announced key state or not. The
real decision point for C is the key-state proposal, which is why
[the `ProposedEvent` gap](#consent-on-both-sides) is the most important small change
in this design.
It also means P2's quorum can be spent on an offer that is declined. That is not new —
founding has the same ordering, and `proposeBirthCertificate` runs before the child's
quorum has signed anything either — but at founding the child's admins have at least
*seen* the intent, because `DkgRitualEvents`' `parent_group` tag rides on the ceremony
proposal and the ritual screen says which group it is for. Adoption has no ceremony
and so no such hint.
### So C's admins need telling, and the arm to tell them already exists
Without one, the first thing an admin of C hears about the whole business is a
proposal asking for their signature.
`ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch. Today it calls
`SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` — the
certificate's parent must be the room it arrived in — and drops anything else. A
certificate that instead names *this* room as its subject, and verifies against the
parent it names, is the arriving offer:
| the certificate | the line |
|---|---|
| signed by this room, about another | "The group made #Translators a subgroup…" (today's) |
| about this room, signed by another | "#Ekklesia has offered to adopt this group" |
Both branches are fully verified and neither needs a lookup: `certifies` checks the
signature against a room id, and a room id is a public key. Nothing is stored — the
`GroupSignedEvent` foreign key onto `ChatRoom` forbids it, and nothing needs it,
because the certificate reaches C's devices for real inside the key state at step 2.
The line is notice, not evidence, and should read as an offer rather than as a fact
about C's lineage.
Delivering the certificate into C is then one act by the member who is in both, and it
is the same act that gives the step-2 proposer the event they need.
### Which screen it starts from
Two defensible entry points, and they produce an identical certificate:
- **C's detail screen**, beside the parent card: *"add a parent group"*. The child is
the one acquiring the relationship, its quorum is the one that ultimately decides,
and it mirrors the parent screen's existing *"add subgroup"*.
- **P2's detail screen**, beside *"add subgroup"*: *"adopt an existing group"*. This is
where the proposal actually lands, and where P2's admins would look for what their
group has taken on.
C's screen is the better default, for the mirroring and because the intent usually
originates with the group looking for a parent. P2's is worth adding later; the guards
are the same set read in the other direction, and the candidate list becomes "Marmot
rooms I hold a key state for", since that is where `subgroup_key` comes from.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
## Consent, on both sides
The parent's side already works. A P2 admin sees `ProposedEvent`'s 30329 summary —
the name and the admin count — and their device checks
`marmotGroupId(subgroup_key, frost_path) == content` before they agree. Unchanged.
**The child's side does not, and this is the gap that has to close.**
`ProposedEvent`'s 30326 arm reads:
```
label: "This group's shared key"
detail: "m/9420/0/0 · a1b2c3d4e5f6"
```
A child admin approving an adoption would be shown the path and a ceremony id, and
nothing whatever about the fact that they are accepting a parent. The proposal is
their only chance to check — after it, a quorum has spoken — so the arm has to
read the parentage off the tags it is already holding and say which groups are
being accepted, and which are being dropped relative to the current row.
That is the single most important small change in this design. Everything else is
plumbing; this is the one place where a member is asked a question and is
currently not being told what it is.
## Guards
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
Two new refusal sets, both in the shape `SubgroupManager.refuseSubgroup`
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
already has: checked before the button is offered, and again in the manager.
**Before proposing a certificate in P2** — the first three are its existing checks,
retargeted from "the parent of a new subgroup" to "the adopting group":
| refused | because |
|---|---|
| P2 is not a Marmot room with readable group data | no admin list to consult; the same reason the subgroup button is hidden on a NIP-17 room |
| this device is not an admin of P2 | a non-admin proposing the group's signature is a proposal the admins must decline by hand |
| this device holds no share of P2's key | `proposeSigningBatch` throws, and throwing at a button is not a UI |
| this device does not hold C's key state | there is no `subgroup_key` to certify, and a certificate without one fails `certifies` question 4 on every device including this one |
| C **is** P2 | a group is not its own parent |
| P2 is already among C's parents | that is a re-certification, not an adoption. `proposeBirthCertificate`'s `check(certificateFor(...) == null)` already refuses it; it should say so in those words |
| P2 is a descendant of C, as far as this device can see | a cycle. Bounded walk, advisory only — see below |
**Before proposing the key state in C:**
| refused | because |
|---|---|
| this device holds no share of C's key | same reason |
| any certificate in the proposed set fails `certifies` | already checked by `propose`; now per pair. Proposing a state this device would drop spends a quorum's attention on nothing |
| the set has a duplicate parent | check (5), at the door rather than only on read |
| the set is unchanged from the current row | nothing to agree |
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
**"This device" in both tables means the profile in the chair**, and `canSign` does
not currently tell the two apart — see
[one human, two profiles](#one-human-two-profiles-and-an-unscoped-read). Until it
does, these guards are honest about capability and silent about persona.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
## New failure modes
**A lineage is a DAG now, so a walk can loop.** With one parent it is a tree and
`parentOf` returns a single id, so nothing was tempted to recurse. A cycle is
constructible — C's quorum certifies P1 using P1's public threshold key, and P1's
quorum re-announces its own key state accepting it, which this design is precisely
what makes possible. It takes both quorums cooperating in something pointless, but
any ancestor walk must be depth-bounded and visited-set guarded regardless, and
the guard above is only as good as what this device can see. `subgroups.md` already
notes depth 2 is untested; this makes it worth testing rather than merely noting.
**A half-adopted state exists between the two steps** — P2 has certified and C has
not accepted — and every reader has to render it as an ordinary intermediate rather
than as an error. Note it is *invisible* from C's side unless the certificate has
been brought across, which means the child's screen cannot show "P2 has certified
you" for a member who is not in P2.
**A re-announcement that forgets an existing parent drops it silently.** Addressed
by the API shape above, and worth a test that names it.
## The UI
Two rungs on the child's `ChatRoomDetailScreen`, beside the parent card. No new
screen, and no new approval UI at all — both proposals are ordinary
`FrostSigningEvents.PROPOSAL`s that `ProposalListScreen` and `FrostSigningScreen`
already show and approve.
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
**"Add a parent group"** opens the picker described under [choosing the
parent](#choosing-the-parent) and proposes the certificate in whichever group is
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
chosen. The picker is a pushed screen, so it takes an `onNavigateBack` and puts
`NavigateBackButton` in its app bar's leading slot — `CLAUDE.md` gained that rule
while this note sat unbuilt, and the desktop target has neither a system back nor an
edge swipe to fall back on. Offered only where step 1's guards pass, hidden rather than disabled, in the
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
same way the shared-key entry is hidden on an MLS room — and empty for a member who
administers none of C's candidate parents, which is a fact about them rather than a
failure.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
**"Accept #P2 as a parent"** appears once a certificate this device can see has
been signed, and proposes the re-announced key state. Offered to any member of C
holding a share — not only to whoever started, for the same reason `subgroups.md`
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
gives for the ritual screen's rung. Its wording is the decision, not the mechanism:
C is accepting an offer, and the transcript line from the arriving certificate is
what it is accepting.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
**The parent card becomes up to N cards.** It reads
`ChatRoomDetailUIState.parentChatRoomId` ← `chatRepository.parentOf` ←
`GroupKeyState`, so it is a straight plural, and it keeps the rule it has: absent
rather than guessed where the state is not held.
**`SubgroupManager.Subgroup` gains a "still claims us" signal**, off the `keyState`
field it already carries. Only where the device holds the child's state; silent
otherwise.
## Tests
- `SubgroupBirthCertificateEventTest` — **unchanged, and that is the assertion**.
`certifies` does not change, so all seven cases including the one whose job is
to fail if a roster check is added must keep passing with no edits.
- `GroupKeyStateTest` (commonTest, pure) — two parents both good; two parents with
one certificate missing → drop; the same parent twice → drop; a single-parent
state in today's shape still accepted, which is the compatibility case; and an
empty set on a state carrying no parentage tags, which is every group made
before this.
- A pairing-order test, because the reason for pairwise emission is a build that
does not exist yet: assemble a two-parent state, read it with the *old*
first-tag-wins parsers, and assert the pair that comes out is coherent.
- `SignedGroupKeyStateTest` (jvmTest) — the one that proves the flow. A real child
key, a real P1 key and state, a real P2 key, a real certificate by P2 over the
child, a real **re-announced** state carrying both, and the assertion that
`stateFrom` accepts it and rejects each single-field mutation.
docs: stop the adoption note naming a schema version, since two landed under it Seventy-one commits of `mantra` merged in -- npub and nsec sign-in, several profiles on one device, three pulls from the Curated fork, a profile preview and a FAB sweep -- and none of it touches subgroups. `docs/subgroups.md`, `SubgroupManager`, `GroupKeyStateManager`, `GroupKeyStateEvent`, `nostr/subgroup/`, `GroupKeyState` and `ProposedEvent` are byte-identical, so the design stands as written. The only `FrostSigningManager` change is a comment, recording that putting a signed event on relays is now a member's separate act from `BroadcastGroupSignedEventScreen`. **The note said schema 19 -> 20, and 20 was taken while it sat there.** 19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`; 20 is the several-profiles pull's, putting an owning identity on the queues. This is the second time the number has moved under a note that is not being built yet -- it was 18 -> 19 when first written three weeks ago -- which is twice too often for a figure nothing depends on. So it now says to read the version off `MantraDatabase` at build time, and the test line that named one does not. Everything else re-checked against the merged tree and unchanged: the four parent-indexed DAO reads are still test-only, `ChatRoom.parentChatRoomId` still has no production reader, `describe(purpose, path)` still takes no parent, and `GroupSignedEvent` still declares its foreign key onto `ChatRoom` -- the last of which is load-bearing for this design, since it is what forbids a child device storing a certificate signed by a group it is not in, and so forces the certificate to ride inside the key state. `docs/README.md` merged without a conflict: the row for this note sits where it was put, under the subgroups row, and the seven rows the sign-in and pull notes added append below it. Merged through the app's host sync rather than `git merge`, because the merge brings in a `.gitmodules` line -- `branch = master` on the lightning-kmp-app submodule -- which the sandbox is not meant to write. Docs only -- no code changed by this commit, so there are no test counts to report. The merged changes have not been built here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:10:16 +02:00
- `SubgroupDaoJvmTest` — the new schema round-trips the table, a newer state replaces
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
the parentage rows of an older one, and a re-announcement that drops a parent
leaves no row behind.
docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by `processGroupMembershipChanges` on an arriving commit, so on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it, because the certificate reaches C's devices for real inside the key state at step 2. The line is notice, not evidence, and must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:46:40 +02:00
- `SubgroupManagerJvmTest` — the two guard tables, and the candidate filter: a room
this device is in but does not administer is not offered as a parent, and neither
is one it administers but holds no share of.
- `ChatMessageJvmTest` — the 30329 arm's second branch: a certificate about this room,
signed by another, writes the offer line; one signed by neither this room nor the
parent it names writes nothing.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
## Rollout
Additive apart from the migration.
`assembleTags` emits pairs only where a lineage is passed, so every state already
signed verifies exactly as it did. The plural parsers read a single pair as a set
of one. The new checks fire only on a state carrying more than one pair.
`certifies` does not change. **The founding flow is not touched at all** — a
subgroup is still founded with one parent, and multi-parent is reached by founding
normally and then adopting.
An older build meeting a two-parent state files a true, partial lineage — one
parent, verified — as long as the tags are pairwise. An older build meeting a
re-announced *single*-parent state sees an ordinary key state and replaces its row,
which is correct.
The migration is the only non-additive part, and the risk in it is the write path
rather than the read: `replace` becoming transactional is where a bug would put a
stale parent on a state that no longer claims it.
Phase 1 (the wire) and phase 2 (the schema) can ship dark. The rest is inert
without the two rungs, so the flag, if one is wanted, is again the button.
## Considered and rejected
**Founding a subgroup with two parents at once.** The version this question
docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony runs in *the parent's room*, and a child with two parents has two candidate rooms and no principled way to choose. `DkgSession.chatRoomId` is one column, the ceremony's messages ride that room's MLS transport, and the members of the other parent -- who may well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:59:19 +02:00
replaced. An earlier draft rejected it on costs Phase 10 has since removed — the
member-derived ceremony room and the parent set as a `DkgSession` discriminator — so
the argument has to be made again, and it comes out the same way for a better reason.
**Phase 10 makes founding with two parents harder, not easier.** The ceremony now runs
in *the parent's room*, and a child with two parents has two candidate rooms and no
principled way to choose: `DkgSession.chatRoomId` is one column, the ceremony's
messages ride that room's MLS transport, and the members of the other parent — who may
be admins of the child — are not in it to receive them. Running it in both means two
ceremonies and two keys, which is two children. Running it in one means the other
parent's admins take part in a ceremony held somewhere they cannot see, which is not a
thing MLS will do.
Adoption sidesteps the question entirely by not holding a ceremony, and what remains
against founding-with-two is unchanged: it still needs a co-parent tag so certificates
can agree on the set, a coordinator who administers both parents, and two quorums
answering before the child room exists at all — and it still cannot adopt a group that
already exists, which is the case people actually have. Founding with two parents stays
reachable: found with one, adopt the second, and the room is a few minutes old instead
of a few seconds.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
**A separate adoption kind, leaving the key state alone.** Cleaner-sounding: a new
event by the child's quorum saying "we accept P2", so the founding fact is never
rewritten. It splits the lineage across a founding key state plus N adoption
events, and then a device holding the state but missing adoption #2 has an
incomplete lineage **and no way to tell that it is incomplete**. One replaceable
statement is always whole or absent. That property is worth more than the
tidiness of not re-signing.
**Filing foreign certificates on the child's devices.** Closed by
`GroupSignedEvent`'s foreign key onto `ChatRoom`, and unnecessary: the certificate
rides inside the key state.
**An `exclusive` flag on the certificate**, meaning "we certify C on condition it
has no other parent", enforced by dropping any state whose set has more than one
member when any certificate claims it. Cheap and checkable, and it hands a group
that has gone quiet a permanent veto over a group that has not. If exclusivity is
ever wanted it should be a social arrangement, not a tag.
**Requiring every certificate to agree on the parent set.** Impossible here, and
the reason is the interesting part: P1 signed before P2 was a possibility. See
[the invariant](#the-invariant-and-how-it-differs-from-founding-time-multi-parent).
## Found while reading
docs: reconcile the adoption note with several profiles and a broadcast button The seventy-one commits left the subgroup code byte-identical, so the first pass over this note changed only a schema number. That was too quick a reading. None of what arrived is *about* subgroups and four pieces of it bear on this design anyway. **A built, unreachable path would put a birth certificate on public relays.** `BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member picks, and its own comment names the property that makes it dangerous here: the signature is the group's, so any member holding the event can send it and no share of the key is needed. It is not restricted by kind -- "the seed is the same for every event of every kind" -- and nothing opens it today only because the rows that carried the button went with the curated lists. The route is still registered and `BroadcastButton` still built. A certificate is the worst event in the app to send: the parent-child lineage, the child's threshold key, and the founding admin roster in its `p` tags, which is a membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with everything that never leaves an encryption, and `SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on the child's room id, so relays would start treating certificates as replacements of one another. So the note now carries a negative rule: the subgroups list and the parent cards must never grow a broadcast button. It is also why relays are not the answer to the overlap requirement, which they otherwise look like -- the price is publishing the lineage and the roster to anyone, to save one person being in two groups. **"One person in both groups" has a second reading now, and the mechanism wants care.** The same human can be a P2 admin under one profile and hold C's share under another, which satisfies both steps with no single key in both groups. `ChatRoom` is keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and the shares all hang off the room -- so the two rooms are two rows with different holders in one database, and `getById` on the certificate is not scoped by identity. It works by reading across a boundary the app has already had to defend once: that commit exists because the Welcome branch could not tell a redelivered Welcome from one for a room another profile holds, and told nobody. **And the check this design leans on does not draw that boundary either.** `DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and `parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every sentence in this note saying "this device holds no share" means the profile in the chair, and both guard tables now say so. Device-local and pre-existing -- both profiles' secrets are on one disk, owned by one person, so nothing crosses a user boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own look rather than fixed here, since it is load-bearing for every "can this act" check in the app and not only for these. **The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md` now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on anything reached by `navigate(...)`, because the desktop target has neither a system back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the twenty-third. Checked and needing nothing: the subgroups section and the parent card are still inline on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open their own pushed screens, and it is worth knowing that pattern exists, but it took the group's profile, relays, posts, schemas and entries -- it left the members and the subgroups where they were, which is where this note puts its two rungs. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
Six things, none caused by this, all worth knowing.
**A built, unreachable path would put any group-signed event on public relays.**
`BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in
one button. Argued above as the
[channel that must not be used](#the-one-channel-that-must-not-be-used-and-it-is-already-built);
listed again here because it is a fact about the tree rather than about this design,
and the next person to wire a broadcast button will not be reading this note.
**`ChatRoom` is keyed by the group id alone, not by (group, profile).** So two
profiles on one device cannot both hold one group — the second one's Welcome is
refused by `679d5249` — while rows belonging to different profiles sit in one database
with nothing scoping a read by id across them.
**`FrostSigningManager.canSign` is device-wide, not profile-wide.**
`getKeyHoldingSessions()` carries no identity predicate although `DkgSession` has the
column, so the active profile can resolve a key whose share another profile
generated. Device-local and pre-existing, and a profile-isolation gap rather than a
cross-user one, but it is load-bearing for every "can this act" check in the app and
not only for the ones here.
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
**`ChatRoom.parentChatRoomId` is write-only in production.**
`MarmotGroupCreation` sets it; its only reader is
`ChatRoomDao.observeByParentChatRoomId`, whose only callers are tests. So are
`GroupKeyStateDao.getByParentChatRoomId`, its `observe` sibling, and
`DkgSessionDao.getByParentChatRoomId`. The live reads both go elsewhere —
`parentOf` off `GroupKeyState`, `subgroupsOf` off the certificates.
**`ProposedEvent`'s 30326 arm says nothing about parentage.** It shows the path
and the ceremony id. Today that is harmless, because a key state carrying a
parentage is only ever proposed at founding, where the proposer is the
coordinator who chose it. It stops being harmless the moment a key state can be
re-announced.
**`GroupSignedEvent` cannot hold another group's event**, by its foreign key onto
`ChatRoom`. Worth knowing before designing anything that wants to keep evidence
about a group this device is not in.
**The description hint from Phase 6 was never built.**
[subgroups.md](./subgroups.md#the-description-carries-the-parent-too-as-a-hint)
says `SharedKeyDerivation.describe` gains a `Parent group:` line so a member
welcomed into a subgroup after its founding learns it is somebody's child.
`describe(purpose, path)` has no such parameter and emits no such line — so the
"one member and one problem" that section describes is unaddressed even for one
parent. It is also unfixable by this design for a *later* parent, since the
description is baked into the epoch-0 group context and adoption happens after it.
## Rough size
docs: record what the adoption plan built, and the eight places it chose differently The note shipped as research and is now five commits of code, so it says so, in the shape docs/subgroups.md uses: the phases kept as written because they are the reasoning, and a table of where the build disagreed with them. Eight entries, and two are worth more than the rest. **A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent** -- author, subject and named parent one room, the id deriving from the key, the signature verifying. The note never considered it. The self-parent guard began inside the offer branch, where it never ran, because the child branch catches such a certificate first and reads it as an ordinary child. A test caught it, and the gap predates this work: `subgroupsOf` would list the group as its own subgroup today. **The certificate's roster.** The note was careful that a founding certificate's name and `p` tags are the founding roster, frozen, and said nothing about adoption -- where there is no founding. The adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now, and the current roster is the honest value rather than a compromise. Still frozen the moment it is signed, still never read as live. The rest are smaller and all one shape, which is a note counting a thing and missing its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that travelled through four files to reach its only writer; `stateFrom` returning "a state and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that is also a plain data class, so the pure verifier builds one with no database near it. One thing the note asked for is **not** done and cannot be: the key-state summary saying which parents are being *dropped*. A summary is pure and a disavowal is a difference against the row this device holds, so it belongs to the screen. The rough-size table becomes estimated against actual. It guessed about two dozen files and it was 39, with the UI three times its estimate -- a picker needs a route, a screen, a view model and a state, which the note had as one line -- and the repositories half of it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files, 53 new cases. `docs/README.md` moves the note from research to a phased plan that has been built, and its one-line summary now ends on the self-certificate rather than on the consent gap, which is closed. Docs only -- no code changed, so there are no test counts of this commit's own. The five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:18:17 +02:00
What it estimated, against what it took:
| | estimated | actual |
docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **The founding flow is not touched at all.** A subgroup is still founded with one parent and multi-parent is reached by founding normally and then adopting, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:42:59 +02:00
|---|---|---|
docs: record what the adoption plan built, and the eight places it chose differently The note shipped as research and is now five commits of code, so it says so, in the shape docs/subgroups.md uses: the phases kept as written because they are the reasoning, and a table of where the build disagreed with them. Eight entries, and two are worth more than the rest. **A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent** -- author, subject and named parent one room, the id deriving from the key, the signature verifying. The note never considered it. The self-parent guard began inside the offer branch, where it never ran, because the child branch catches such a certificate first and reads it as an ordinary child. A test caught it, and the gap predates this work: `subgroupsOf` would list the group as its own subgroup today. **The certificate's roster.** The note was careful that a founding certificate's name and `p` tags are the founding roster, frozen, and said nothing about adoption -- where there is no founding. The adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now, and the current roster is the honest value rather than a compromise. Still frozen the moment it is signed, still never read as live. The rest are smaller and all one shape, which is a note counting a thing and missing its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that travelled through four files to reach its only writer; `stateFrom` returning "a state and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that is also a plain data class, so the pure verifier builds one with no database near it. One thing the note asked for is **not** done and cannot be: the key-state summary saying which parents are being *dropped*. A summary is pure and a disavowal is a difference against the row this device holds, so it belongs to the screen. The rough-size table becomes estimated against actual. It guessed about two dozen files and it was 39, with the UI three times its estimate -- a picker needs a route, a screen, a view model and a state, which the note had as one line -- and the repositories half of it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files, 53 new cases. `docs/README.md` moves the note from research to a phased plan that has been built, and its one-line summary now ends on the self-certificate rather than on the consent gap, which is closed. Docs only -- no code changed, so there are no test counts of this commit's own. The five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:18:17 +02:00
| wire | 1 | 2 — `GroupKeyStateEvent` plus the `SubgroupLineage` type beside it |
| schema | 5 | 7 — two new files (`GroupKeyStateParent`, `LocalGroupKeyState`), the migration, two DAOs, `MantraDatabase`, the builder |
| managers | 2 | 3 — `MarmotGroupCreation` as well, losing the parameter nothing fed any more |
| repositories | 4 | 2 — one interface and one implementation; `DkgRepository`'s changes were return types the note did not count |
| text | 2 | 2 |
| UI | 5 | 10 — a route, a screen, a view model and a UI state for the picker, plus the detail screen's four and the strings |
| tests | 8 | 7 files, 53 new cases |
39 files, and the founding flow is not among them: a subgroup is still founded with
one parent, and the second arrives by adoption.
454 common tests, 962 jvm tests, `m3Audit` meets every budget.