Files
mantra-kmp/docs
Kgothatso Ngako 26fef1f331 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
..

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.