Files
mantra-kmp/docs/subgroups-multiple-parents.md
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

35 KiB

Adding a parent to a group that already exists

What it would take for a group to gain a second parent after it was founded. Research, not a plan: nothing here has been built.

Read 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 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.

The short answer

Adoption is two FROST sessions and no ceremony.

  1. birth certificate, in the new parent  -> P2's quorum signs C
  2. key state, in C itself                -> C's quorum re-announces, carrying
                                              every certificate it accepts

That is the whole flow. Compare it with founding, which is four steps:

founding a subgroup adopting an existing group
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 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 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:

  • GroupKeyStateManager.propose already permits a room to propose its own key state: check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId), and for an existing child the first branch holds. Nothing forbids re-announcement today; it is simply never done.
  • 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 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.

Worth stating plainly, because the founding flow bundles them and so hides it.

The certificate is the parent's consent: P's quorum saying "C is our child". The parentage tags on the key state are the child's consent: C's quorum saying "we accept P". Neither alone is a link. A parent cannot adopt a group that has not agreed, and a group cannot claim a parent that has not signed — which is exactly why GroupKeyStateManager.parentageOf drops a state whose certificate does not check out, and why SubgroupManager.proposeBirthCertificate runs in the parent's room rather than the child's.

At founding the two are bundled because the key state has to be signed anyway: it is the founding fact, and the parentage rides along. For an existing group there is no founding fact left to settle, so step 2 is a key state signed purely as a carrier for the child's consent. That is the one slightly odd thing about this design and it is worth doing anyway — see why not a separate adoption kind.

Once that is seen, the whole thing collapses to one sentence:

A group's lineage is a set of (parent, certificate) pairs carried on its key state. Adding a parent is: that parent certifies, then the child re-announces its key state with the pair added. Founding a subgroup is the case where the set has one member and the key state was being signed anyway.

The invariant, and how it differs from founding-time multi-parent

If two parents certified a child at founding, both certificates would be signed at the same moment and could name each other — so a reader could demand that every certificate agree on the whole parent set, and a coordinator could not staple two unrelated certificates into a joint child.

That check is not available here, and cannot be made available. P1 certified C before P2 existed as a possibility. An earlier signature cannot name a later co-parent, and asking P1 to re-certify every time a sibling appears makes every adoption cost a quorum in every existing parent.

So the invariant has to be weaker, and the honest version is:

The child's key state is the sole authority on the lineage set. Each certificate is checked individually — this parent signed for this child — and the set is whatever the child's quorum most recently declared.

Which means a parent gets a veto on being added and no veto on who else is in the set. That is not a compromise; it is the correct answer:

  • Nothing P1 signed becomes false. P1 said "C is our child". C being P2's child as well does not contradict it.
  • Exclusivity was never expressible and could not be enforced anyway. P2 can certify C whether P1 likes it or not — a certificate is a unilateral statement about a public room id. What P2 cannot do is make C accept it.
  • The child's consent is the whole gate, and it should be, because the child is the one that acquires an obligation.

parentageOf therefore grows from four checks to six, and the two new ones are small:

  1. All or none, per pair. (Today's rule.)
  2. Each certificate parses. (Today's.)
  3. Each certifies(certificate, child, itsParent). certifies does not change — it is six questions about one signer and one child and it stays that, so SubgroupBirthCertificateEventTest's existing seven cases keep passing untouched.
  4. Each certificate's subgroup_key is the state's own threshold key. (Today's.)
  5. No duplicate parents. A set with a repeat in it is not a set.
  6. Any failure drops the whole state. (Today's.)

There is no co_parent tag and no cross-certificate agreement check. Both were needed only by the founding-time design, and both are gone.

The footgun this creates

A key state is replaceable and the newest wins wholesale. So re-announcing to add P2 must re-emit P1's pair as well, or P1 is silently dropped.

That is a bad API to leave open. propose should take the resulting set and read the current one off the row, so that "add P2" is expressed as an addition rather than as a full replacement a caller has to remember to assemble. The same call then expresses removal, which is the next section.

What this also unlocks

Two things fall out of the same mechanism, and neither needs extra work.

Any existing robust group can become a subgroup. Nothing in the two steps requires the child to already have a parent. A top-level group with a key state gets adopted exactly the same way, with the set going from empty to one. That is arguably worth more than multi-parent itself, and it is the same code path.

A child can drop a parent. Re-announce with the pair removed. This partly answers subgroups.md's "Removing a subgroup is not a thing" — and only partly, so the limit is worth being precise about:

  • The certificate is forever. Nothing revokes a signature and nothing should pretend to.
  • But a link is two signatures, and the child's half is replaceable. So the child can stop claiming a parent, and every device holding its key state will stop believing the link.
  • The parent's own list does not follow, because SubgroupManager.subgroupsOf reads the parent's certificates, not the child's key state. P1 keeps listing C.
  • That is fixable in the display without any new protocol: Subgroup already carries a keyState field, populated where the device holds the child's state. A parent device that also holds C's key state can see that C no longer names it and say so. A parent device that does not hold it cannot, and must not guess.

The wire

The key state carries pairs, in matched order

No new tag names. parent_group and birth_certificate repeat:

["parent_group",     <P1>]
["birth_certificate", <P1's certificate, as JSON>]
["parent_group",     <P2>]
["birth_certificate", <P2's certificate, as JSON>]

SubgroupParentTag.parse and SubgroupBirthCertificateTag.parse already work per-tag; the readers change from firstNotNullOfOrNull to mapNotNull, and assembleTags takes a list instead of a nullable single. That is the entire wire change.

The order is load-bearing, and only for old builds. GroupKeyStateEvent.parseParentChatRoomId and parseBirthCertificate take the first of each, so a build predating this pairs parent_group[0] with birth_certificate[0] and runs certifies on them:

  • emitted pairwise — P1, P1's cert, P2, P2's cert — it pairs P1 with P1's certificate, it verifies, and the device files a true but partial lineage. Correct, just less.
  • emitted grouped — both parents, then both certificates — it is coherent only while both lists happen to be in the same order, and silently wrong the moment they are not. Then certifies fails and the old build drops the whole state, losing the row that says which ceremony's share signs for that room. Recoverable through completedKey's rederivation fallback, and only for default-path rooms.

So: emit pairwise, sorted by parent id so every device produces the same bytes. Free, and the difference between graceful degradation and an old device quietly losing its key state.

The certificate does not change at all

Kind 30329 keeps its tags, its certifies, and its meaning. A certificate signed for a founding subgroup and one signed for an adoption are the same event — which is right, because they say the same thing.

The schema

Much smaller than the founding-time version, because DkgSession is not involved.

GroupKeyState: one-to-many. The two columns parentChatRoomId and birthCertificateJson become a table:

@Entity(primaryKeys = ["chatRoomId", "parentChatRoomId"], foreignKeys = [
    ForeignKey(entity = GroupKeyState::class, parentColumns = ["chatRoomId"],
               childColumns = ["chatRoomId"], onDelete = ForeignKey.CASCADE)
])
data class GroupKeyStateParent(
    val chatRoomId: String,
    val parentChatRoomId: HexKey,
    val birthCertificateJson: String,
)

The composite primary key is check (5) — one certificate per (child, parent), enforced by the store rather than by a reader remembering. The cascade is onto GroupKeyState, never onto ChatRoom, so subgroups.md's argument against a self-referential cascade stands untouched: the parent side stays a plain column that routinely names a room this device does not have.

The cost is that filing a state stops being one write. GroupKeyStateDao.replace has newest-wins logic and would have to become a @Transaction that rewrites the 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. 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), and leaving it holding one of several parents makes it a cache that is wrong rather than merely unused.

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

The parentage columns are already a cache: stateFrom derives them from the signed 30326 event, and signedStateFor can re-read the newest such event for any room at any time. So the lineage could be a derived read — parse the signed event, run the checks, return the set — with no schema change whatsoever.

Worth knowing it is available, and worth not taking. signingPath reads the GroupKeyState row on every signature, so the row stays either way; a design where the path comes off the row and the lineage comes off the event is two sources for one statement, which is the thing subgroups.md spends its appendix refusing. Cache the whole state or cache none of it.

Who can actually do this

The one requirement that does not soften, and it should be stated before the UI is designed around it.

Step 1 — proposing the certificate in P2 — needs a P2 admin holding a share of P2's key, who also knows the child's threshold key. The certificate's tags carry subgroup_key, and without it certifies fails at question 4. The child's threshold key lives on the child's GroupKeyState, which only the child's members hold. So the proposer is in both groups.

Step 2 — proposing the key state in C — needs a member of C holding a share of C's key, who has P2's certificate. Certificates are filed as GroupSignedEvent rows by recordSignedEvents, keyed on the session's room, so only P2's members have one. So this proposer is also in both groups.

They need not be the same person, and their capabilities differ — step 1 needs admin rights in P2, step 2 needs a share of C's key. But adoption requires at least one person in both groups, and there is no way around it: the certificate has to cross a boundary that nothing in the app crosses.

That is still much weaker than founding-time multi-parent, where one coordinator had to administer both parents and run a ceremony. And it is a realistic requirement — a group being adopted by another group almost certainly has someone in both.

What would remove it is a cross-group request: a way for C to publish "we would like P2 to adopt us, here is our key" and for P2's admins to act on it. That is a real feature with its own consent questions, and it should not be smuggled in as a helper.

The certificate rides on the key state, and needs no storage of its own

The obvious-looking alternative — file P2's certificate on the child's devices when it arrives — is closed, and the closure is load-bearing: GroupSignedEvent declares a foreign key onto ChatRoom, so a child device with no P2 room row cannot store a P2-signed event at all.

It does not need to. GroupKeyStateManager.propose takes the certificate as an Event, not as a row, and once the child's quorum signs the re-announced state the certificate travels inside it — verified standalone by stateFrom on every child device, exactly as at founding. The proposer needs the event; nobody else does.

Choosing the parent

Worth its own section, because "pick a group" is not what the picker can offer and designing the screen around the wrong list would be the easy mistake.

The list is the selector's own rooms, and cannot be anything else

A certificate is proposed inside the adopting group, by FrostSigningManager.proposeSigning in that room. So the candidate list is not "the groups that exist" or "the groups C's admins can name" — it is the rooms this device can already act in:

getChatRoomListByUserPublicKey(user)
  filter { it is a Marmot room whose MarmotGroupData reads }
  filter { adminPubkeys.contains(userPublicKey) }
  filter { FrostSigningManager.canSign(database, it.id) }
  filter { it.id != C }
  filter { it.id !in C's current parents }
  filter { it.id is not a descendant of C }

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 processGroupMembershipChanges on an arriving commit, so it is null for everybody on a room this device created.

A group cannot be offered as a parent by somebody who is not in it. That is the overlap requirement from who can actually do this showing up as a list length. If nobody in C administers P2, P2 simply does not appear, and the remedy is a person rather than a button.

Cost, and where it goes

toMlsGroup() deserialises the whole group state out of hex to reach adminPubkeys, 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. 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 already builds its prefetch: off the main thread, filling in as it resolves, rather than blocking the screen on the whole list.

The selection is an offer, not a decision

The ordering is forced — the key state carries the certificate, so the certificate is signed first — and that has a consequence worth stating in the UI's own words:

P2's quorum commits first. C's quorum decides second.

Whoever picks P2 is not choosing on C's behalf. They are asking P2 to make an offer, and C's admins answer it at step 2 by signing the re-announced key state or not. The real decision point for C is the key-state proposal, which is why the ProposedEvent gap is the most important small change in this design.

It also means P2's quorum can be spent on an offer that is declined. That is not new — founding has the same ordering, and proposeBirthCertificate runs before the child's quorum has signed anything either — but at founding the child's admins have at least seen the intent, because DkgRitualEvents' parent_group tag rides on the ceremony proposal and the ritual screen says which group it is for. Adoption has no ceremony and so no such hint.

So C's admins need telling, and the arm to tell them already exists

Without one, the first thing an admin of C hears about the whole business is a proposal asking for their signature.

ChatMessage.applyInnerEvent's 30329 arm should grow a second branch. Today it calls SubgroupManager.record, which asks certifies(event, subgroup, chatRoomId) — the certificate's parent must be the room it arrived in — and drops anything else. A certificate that instead names this room as its subject, and verifies against the parent it names, is the arriving offer:

the certificate the line
signed by this room, about another "The group made #Translators a subgroup…" (today's)
about this room, signed by another "#Ekklesia has offered to adopt this group"

Both branches are fully verified and neither needs a lookup: certifies checks the signature against a room id, and a room id is a public key. Nothing is stored — the GroupSignedEvent 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 should read as an offer rather than as a fact about C's lineage.

Delivering the certificate into C is then one act by the member who is in both, and it is the same act that gives the step-2 proposer the event they need.

Which screen it starts from

Two defensible entry points, and they produce an identical certificate:

  • C's detail screen, beside the parent card: "add a parent group". The child is the one acquiring the relationship, its quorum is the one that ultimately decides, and it mirrors the parent screen's existing "add subgroup".
  • P2's detail screen, beside "add subgroup": "adopt an existing group". This is where the proposal actually lands, and where P2's admins would look for what their group has taken on.

C's screen is the better default, for the mirroring and because the intent usually originates with the group looking for a parent. P2's is worth adding later; the guards are the same set read in the other direction, and the candidate list becomes "Marmot rooms I hold a key state for", since that is where subgroup_key comes from.

The parent's side already works. A P2 admin sees ProposedEvent's 30329 summary — the name and the admin count — and their device checks marmotGroupId(subgroup_key, frost_path) == content before they agree. Unchanged.

The child's side does not, and this is the gap that has to close. ProposedEvent's 30326 arm reads:

label:  "This group's shared key"
detail: "m/9420/0/0 · a1b2c3d4e5f6"

A child admin approving an adoption would be shown the path and a ceremony id, and nothing whatever about the fact that they are accepting a parent. The proposal is their only chance to check — after it, a quorum has spoken — so the arm has to read the parentage off the tags it is already holding and say which groups are being accepted, and which are being dropped relative to the current row.

That is the single most important small change in this design. Everything else is plumbing; this is the one place where a member is asked a question and is currently not being told what it is.

Guards

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, retargeted from "the parent of a new subgroup" to "the adopting group":

refused because
P2 is not a Marmot room with readable group data no admin list to consult; the same reason the subgroup button is hidden on a NIP-17 room
this device is not an admin of P2 a non-admin proposing the group's signature is a proposal the admins must decline by hand
this device holds no share of P2's key proposeSigningBatch throws, and throwing at a button is not a UI
this device does not hold C's key state there is no subgroup_key to certify, and a certificate without one fails certifies question 4 on every device including this one
C is P2 a group is not its own parent
P2 is already among C's parents that is a re-certification, not an adoption. proposeBirthCertificate's check(certificateFor(...) == null) already refuses it; it should say so in those words
P2 is a descendant of C, as far as this device can see a cycle. Bounded walk, advisory only — see below

Before proposing the key state in C:

refused because
this device holds no share of C's key same reason
any certificate in the proposed set fails certifies already checked by propose; now per pair. Proposing a state this device would drop spends a quorum's attention on nothing
the set has a duplicate parent check (5), at the door rather than only on read
the set is unchanged from the current row nothing to agree

New failure modes

A lineage is a DAG now, so a walk can loop. With one parent it is a tree and parentOf returns a single id, so nothing was tempted to recurse. A cycle is constructible — C's quorum certifies P1 using P1's public threshold key, and P1's quorum re-announces its own key state accepting it, which this design is precisely what makes possible. It takes both quorums cooperating in something pointless, but any ancestor walk must be depth-bounded and visited-set guarded regardless, and the guard above is only as good as what this device can see. subgroups.md already notes depth 2 is untested; this makes it worth testing rather than merely noting.

A half-adopted state exists between the two steps — P2 has certified and C has not accepted — and every reader has to render it as an ordinary intermediate rather than as an error. Note it is invisible from C's side unless the certificate has been brought across, which means the child's screen cannot show "P2 has certified you" for a member who is not in P2.

A re-announcement that forgets an existing parent drops it silently. Addressed by the API shape above, and worth a test that names it.

The UI

Two rungs on the child's ChatRoomDetailScreen, beside the parent card. No new screen, and no new approval UI at all — both proposals are ordinary FrostSigningEvents.PROPOSALs that ProposalListScreen and FrostSigningScreen already show and approve.

"Add a parent group" opens the picker described under choosing the parent and proposes the certificate in whichever group is chosen. Offered only where step 1's guards pass, hidden rather than disabled, in the same way the shared-key entry is hidden on an MLS room — and empty for a member who administers none of C's candidate parents, which is a fact about them rather than a failure.

"Accept #P2 as a parent" appears once a certificate this device can see has been signed, and proposes the re-announced key state. Offered to any member of C holding a share — not only to whoever started, for the same reason subgroups.md gives for the ritual screen's rung. Its wording is the decision, not the mechanism: C is accepting an offer, and the transcript line from the arriving certificate is what it is accepting.

The parent card becomes up to N cards. It reads ChatRoomDetailUIState.parentChatRoomId ← chatRepository.parentOf ← GroupKeyState, so it is a straight plural, and it keeps the rule it has: absent rather than guessed where the state is not held.

SubgroupManager.Subgroup gains a "still claims us" signal, off the keyState field it already carries. Only where the device holds the child's state; silent otherwise.

Tests

  • SubgroupBirthCertificateEventTest — unchanged, and that is the assertion. certifies does not change, so all seven cases including the one whose job is to fail if a roster check is added must keep passing with no edits.
  • GroupKeyStateTest (commonTest, pure) — two parents both good; two parents with one certificate missing → drop; the same parent twice → drop; a single-parent state in today's shape still accepted, which is the compatibility case; and an empty set on a state carrying no parentage tags, which is every group made before this.
  • A pairing-order test, because the reason for pairwise emission is a build that does not exist yet: assemble a two-parent state, read it with the old first-tag-wins parsers, and assert the pair that comes out is coherent.
  • SignedGroupKeyStateTest (jvmTest) — the one that proves the flow. A real child 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 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 this device is in but does not administer is not offered as a parent, and neither is one it administers but holds no share of.
  • ChatMessageJvmTest — the 30329 arm's second branch: a certificate about this room, signed by another, writes the offer line; one signed by neither this room nor the parent it names writes nothing.

Rollout

Additive apart from the migration.

assembleTags emits pairs only where a lineage is passed, so every state already signed verifies exactly as it did. The plural parsers read a single pair as a set of one. The new checks fire only on a state carrying more than one pair. certifies does not change. 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.

An older build meeting a two-parent state files a true, partial lineage — one parent, verified — as long as the tags are pairwise. An older build meeting a re-announced single-parent state sees an ordinary key state and replaces its row, which is correct.

The migration is the only non-additive part, and the risk in it is the write path rather than the read: replace becoming transactional is where a bug would put a stale parent on a state that no longer claims it.

Phase 1 (the wire) and phase 2 (the schema) can ship dark. The rest is inert 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. 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 rewritten. It splits the lineage across a founding key state plus N adoption events, and then a device holding the state but missing adoption #2 has an incomplete lineage and no way to tell that it is incomplete. One replaceable statement is always whole or absent. That property is worth more than the tidiness of not re-signing.

Filing foreign certificates on the child's devices. Closed by GroupSignedEvent's foreign key onto ChatRoom, and unnecessary: the certificate rides inside the key state.

An exclusive flag on the certificate, meaning "we certify C on condition it has no other parent", enforced by dropping any state whose set has more than one member when any certificate claims it. Cheap and checkable, and it hands a group that has gone quiet a permanent veto over a group that has not. If exclusivity is ever wanted it should be a social arrangement, not a tag.

Requiring every certificate to agree on the parent set. Impossible here, and the reason is the interesting part: P1 signed before P2 was a possibility. See the invariant.

Found while reading

Four things, none caused by this, all worth knowing.

ChatRoom.parentChatRoomId is write-only in production. MarmotGroupCreation sets it; its only reader is ChatRoomDao.observeByParentChatRoomId, whose only callers are tests. So are GroupKeyStateDao.getByParentChatRoomId, its observe sibling, and DkgSessionDao.getByParentChatRoomId. The live reads both go elsewhere — parentOf off GroupKeyState, subgroupsOf off the certificates.

ProposedEvent's 30326 arm says nothing about parentage. It shows the path and the ceremony id. Today that is harmless, because a key state carrying a parentage is only ever proposed at founding, where the proposer is the coordinator who chose it. It stops being harmless the moment a key state can be re-announced.

GroupSignedEvent cannot hold another group's event, by its foreign key onto ChatRoom. Worth knowing before designing anything that wants to keep evidence about a group this device is not in.

The description hint from Phase 6 was never built. subgroups.md says SharedKeyDerivation.describe gains a Parent group: line so a member welcomed into a subgroup after its founding learns it is somebody's child. describe(purpose, path) has no such parameter and emits no such line — so the "one member and one problem" that section describes is unaddressed even for one parent. It is also unfixable by this design for a later parent, since the description is baked into the epoch-0 group context and adoption happens after it.

Rough size

files notes
wire 1 GroupKeyStateEvent — list instead of nullable single, in three functions
lineage type 2 SubgroupParentage kept as one pair; a SubgroupLineage holding the set and its invariants
schema 5 GroupKeyState, a new entity, GroupKeyStateDao (transactional replace), ChatRoomDao, MantraDatabase + migration
managers 2 GroupKeyStateManager (parentageOf, propose), SubgroupManager (parentsOf, the two guard sets, adoption proposals)
repositories 4 two interfaces, two implementations, mostly signature plurals
text 2 ProposedEvent's 30326 arm — the consent fix — and ChatMessage's 30329 arm, for the offer line
UI 5 ChatRoomDetailUIState, its view model and screen, a parent picker, strings
tests 8

Around two dozen files, and the founding flow is not among them.