Commit Graph

3 Commits

Author SHA1 Message Date
Kgothatso Ngako
516fa5cc3c 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>
2026-09-09 16:59:19 +02:00
Kgothatso Ngako
7d54fcdc34 docs: say which rooms an adoption picker can offer, and who is actually deciding
The note said "add a parent group opens a picker of candidate rooms" and left the
list unspecified, which is the easy place to design the wrong screen. It is not the
groups that exist, and not the groups C's admins can name.

**A certificate is proposed inside the adopting group, so the list is the selector's
own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be
a Marmot room this device already administers and holds a share of -- which is
`SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus
C itself, C's existing parents and C's descendants removed. The admin test reads
`MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that
function already gives: the flag is written only by `processGroupMembershipChanges` on
an arriving commit, so on a room this device created it is null for everybody.

So a group cannot be offered as a parent by somebody who is not in it. The overlap
requirement the note already states turns up here as a list length, and an empty
picker is a fact about the member rather than a failure -- the remedy is a person, not
a button.

**The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()`
deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is
cheap for exactly the rooms that pass, because `completedKey`'s first branch is a
key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with
a `marmotGroupId` derivation per session, fires only for rooms with no key state, which
fail the filter anyway. Built the way
`SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its
prefetch -- off the main thread, filling in as it resolves.

**Picking a parent is making an offer, not taking a decision.** The ordering is forced
-- the key state carries the certificate -- so P2's quorum commits first and C's quorum
answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at
step 2 by signing the re-announced key state or not. That is why the `ProposedEvent`
30326 arm is the most important small change in the design, and the note now says so
from this direction too.

**And it exposes a gap the founding flow does not have.** Founding has the same
ordering -- `proposeBirthCertificate` runs before the child's quorum has signed
anything -- but a subgroup's admins have at least *seen* the intent, because
`DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen
names the group. Adoption has no ceremony and so no hint, which means the first thing
an admin of C would hear is a request for their signature.

`ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it
calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` --
the certificate's parent must be the room it arrived in -- and drops everything else. A
certificate that instead names *this* room as its subject, and verifies against the
parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to
adopt this group", beside today's "The group made #Translators a subgroup". Both
branches are fully verified and neither needs a lookup, since `certifies` checks a
signature against a room id and a room id is a public key. Nothing is stored --
`GroupSignedEvent`'s foreign key onto `ChatRoom` forbids it and nothing needs it,
because the certificate reaches C's devices for real inside the key state at step 2. The
line is notice, not evidence, and must read as an offer rather than as a fact about C's
lineage.

**Two entry points are defensible and they produce an identical certificate.** C's
detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or
P2's beside that button, where the proposal actually lands. C's is the better default,
because the intent originates with the group looking for a parent and its quorum is the
one that decides. P2's is worth adding later, with the same guards read in the other
direction and a candidate list of "Marmot rooms I hold a key state for", since that is
where `subgroup_key` comes from.

Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this
device is in but does not administer is not offered, and neither is one it administers
but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves
from six tests to eight and from one text file to two.

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:46:40 +02:00
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