33720238292af06db2cb5385f601c4ea6f32c9ff
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |