Commit Graph

1 Commits

Author SHA1 Message Date
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