From 516fa5cc3c0428fed75056dc20029bd58ef1763b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:59:19 +0200 Subject: [PATCH] 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 --- docs/subgroups-multiple-parents.md | 87 ++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index da4b2a9e..ccb82fac 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -7,7 +7,12 @@ 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. -Written against the code as it stands at `45cc80b5`. +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). ## The short answer @@ -25,17 +30,24 @@ That is the whole flow. Compare it with founding, which is four steps: |---|---| | 1. ChillDKG among the child's admins | — the key already exists | | 2. birth certificate, in the parent | **the same step, unchanged** | -| 3. key state, in the NIP-17 ceremony room | **the same event, in the child's own Marmot room** | +| 3. key state, in the parent's room where the ceremony ran | **the same event, in the child's own Marmot room** | | 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 -is also what drags in the NIP-17 ceremony room, the member-derived room id, the -collision guard, and `DkgSession.parentChatRoomId` as a discriminator. None of -that is reachable from here. **The whole of `DkgSession`, `ChillDkgRitualManager`, -`MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung -is untouched by this design.** +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. Three things in the existing code make step 2 work as it stands: @@ -46,9 +58,11 @@ Three things in the existing code make step 2 work as it stands: - `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. - The `mlsGroupState == null` special case — the one `subgroups.md` flags as the - only un-self-checkable path in the app — is **not** reached, because the child's - room exists. + 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. - `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. @@ -231,15 +245,20 @@ has newest-wins logic and would have to become a `@Transaction` that rewrites th parentage rows with the state, or a dropped parent survives the re-announcement that dropped it — which is exactly the operation this design adds. -**`DkgSession`: untouched.** No discriminator, no canonical joined string, no -migration. This is the largest single saving over founding-time multi-parent. +**`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. **`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. -Schema **18 → 19**: one new table, one dropped column, and the existing rows -carried across as a single parentage row each. Not an `AutoMigration`; schema +Schema **19 → 20** — 19 is Phase 10's, for `DkgSession.participantPublicKeys` and +`subject`: 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. ### The zero-migration alternative, and why not @@ -321,7 +340,7 @@ getChatRoomListByUserPublicKey(user) filter { it.id is not a descendant of C } ``` -The first three are `SubgroupManager.refuseCeremonyRoom`'s existing checks with the +The first three are `SubgroupManager.refuseSubgroup`'s existing checks with the target moved from "the parent of a new subgroup" to "the adopting group", and the admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by @@ -339,7 +358,10 @@ remedy is a person rather than a button. 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 -session) only fires for rooms with no key state, which fail the filter anyway. +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. So the filter is one MLS decode per room and two indexed reads. Fine at twenty rooms and worth building the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` @@ -434,7 +456,7 @@ currently not being told what it is. ## Guards -Two new refusal sets, both in the shape `SubgroupManager.refuseCeremonyRoom` +Two new refusal sets, both in the shape `SubgroupManager.refuseSubgroup` 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, @@ -526,7 +548,7 @@ otherwise. 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. -- `SubgroupDaoJvmTest` — schema 19 round-trips the table, a newer state replaces +- `SubgroupDaoJvmTest` — schema 20 round-trips the table, a newer state replaces the parentage rows of an older one, and a re-announcement that drops a parent leaves no row behind. - `SubgroupManagerJvmTest` — the two guard tables, and the candidate filter: a room @@ -562,15 +584,26 @@ 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 -replaced, and worth recording why the incremental framing wins. It needs the -ceremony's parent set to become a canonical discriminator on `DkgSession`, a -co-parent tag on the certificate so all certificates can agree on the set, a -coordinator who administers *both* parents, two quorums answering before the child -room can be created at all, and a widened `refuseCeremonyRoom`. Roughly thirty -files against this design's dozen — and it delivers strictly less, because it -cannot adopt a group that already exists, which is the case people actually have. -Founding with two parents remains reachable afterwards: found with one, adopt the -second, and the room is a few minutes old instead of a few seconds. +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. **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