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>
This commit is contained in:
Kgothatso Ngako
2026-09-09 16:42:59 +02:00
parent 45cc80b538
commit 26fef1f331
2 changed files with 528 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ silent, or a decision that looked arbitrary and was not.
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
| [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the one screen that currently asks without saying what it is asking |
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
@@ -31,7 +32,11 @@ and the one thing they cannot. The
jvm-target note is unrelated to all of them: it is a build and packaging story.
The subgroups note is a phased plan that has been built; it assumes both
shared-key notes and reads as the ceremony's second half — what a group does
once it has a key, and what it can say about a group that does not yet.
once it has a key, and what it can say about a group that does not yet. The
multiple-parents note is research rather than a plan, and reads only after it:
it takes the one assumption that note never had to state — that a lineage is
settled at founding — and finds that dropping it costs less than founding a
subgroup does, because no ceremony is involved.
The Material Design note is a phased plan that has not been built, and is the
only one about what the app looks like rather than what it does; read the
jvm-target note first if you want to know why its adaptive-layout phase exists.

View File

@@ -0,0 +1,522 @@
# 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](./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`.
## 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 NIP-17 ceremony room | **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.**
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 `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.
- `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.
## A parent link is two signatures, one from each side
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](#considered-and-rejected).
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:
```kotlin
@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.** No discriminator, no canonical joined string, no
migration. This is the largest single saving over founding-time multi-parent.
**`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
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.
## Consent, on both sides
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.refuseCeremonyRoom`
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.PROPOSAL`s that `ProposalListScreen` and `FrostSigningScreen`
already show and approve.
**"Add a parent group"** opens a picker of candidate rooms: the Marmot rooms this
device administers and can sign in. Choosing one proposes the certificate there.
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.
**"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.
**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 19 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.
## 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, 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.
**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](#the-invariant-and-how-it-differs-from-founding-time-multi-parent).
## 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](./subgroups.md#the-description-carries-the-parent-too-as-a-hint)
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 | 1 | `ProposedEvent`'s 30326 arm — the consent fix |
| UI | 5 | `ChatRoomDetailUIState`, its view model and screen, a parent picker, strings |
| tests | 6 | |
Around two dozen files, and the founding flow is not among them.