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>
This commit is contained in:
Kgothatso Ngako
2026-09-09 16:59:19 +02:00
parent cff2c3c329
commit 516fa5cc3c

View File

@@ -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