`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>
mantra docs
Notes on the parts of this app whose behaviour is not recoverable by reading the code alone — where the reasoning lives in a protocol, a failure mode that is silent, or a decision that looked arbitrary and was not.
| document | covers |
|---|---|
| shared-key-ceremony.md | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| shared-key-derivation.md | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| subgroups.md | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
| subgroups-multiple-parents.md | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the one screen that currently asks without saying what it is asking |
| frost-batch-signing.md | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
| marmot-membership.md | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| member-chronicle.md | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
| marmot-direct-messages.md | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it |
| mls-skipped-keys.md | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
| long-running-sync.md | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
| dead-code.md | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover |
| jvm-target.md | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
| material-design-conformance.md | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" report — it is silent, and it looks like every other kind of delivery failure. The sync note stands alone, and the dead-code inventory reads as a follow-up to it. The batch-signing note is a phased plan that has been built: read it after the derivation note, whose one rule is the same one it is built around. The member chronicle note is a phased plan that has not been built, and reads as the membership note's unanswered half: what a member who joins late can be given, and the one thing they cannot. The jvm-target note is unrelated to all of them: it is a build and packaging story. The subgroups note is a phased plan that has been built; it assumes both shared-key notes and reads as the ceremony's second half — what a group does once it has a key, and what it can say about a group that does not yet. The multiple-parents note is research rather than a plan, and reads only after it: it takes the one assumption that note never had to state — that a lineage is settled at founding — and finds that dropping it costs less than founding a subgroup does, because no ceremony is involved. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists.