Both rungs after a subgroup's ceremony run in the **parent's** room. The birth
certificate is the parent's signature, so it is signed where the parent's key is; the
key state is signed over the parent's room because the room it is *about* does not
exist yet -- that is the whole point of signing it first. So a parent making its second
subgroup has two certificate signings and two key-state signings in one room, and the
ceremony screen read them out by event kind alone.
`observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind
match is "the newest of that kind in this room" -- which is some other subgroup's. The
screen for one child followed another child's progress.
**The display was not the damage.** Both rungs disable their button while a signing of
that kind is running and un-failed:
```kotlin
enabled = … && (certificateSession == null || certificateFailed)
```
and a *completed* signing is un-failed forever. So once a parent had certified one
subgroup, it could never be asked to certify another: the rung sat there disabled with
no way out, and `proposeBirthCertificate` returned early on the same reading. The key
state blocked the same way, and `createAdminGroup` is gated behind it, so the second
subgroup could not be made at all.
**It did not need two subgroups.** Adoption signs a key state in the child's own room
(`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been
adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the
first subgroup it tries to make.
What makes this a half-finished job rather than an oversight is that the *signed* facts
were already right. `isCertified` goes through `observeBirthCertificate`, scoped by
session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own
comment records this exact lesson being learned once already: "A ceremony room holds
more than one ceremony the moment a subgroup's admins are the whole group." The
in-flight lookups never got the same treatment, and the comment above the key-state
watcher still asserted the premise that had stopped being true -- "the room a ceremony
runs in signs nothing else".
**The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent`
carries `FrostKeyTag` from the moment the proposal is assembled, so
`parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key
and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with
its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed
per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s
null branch now clears `keyStateSession` too, which it could not before because the
field was not its to clear.
**The certificate is matched on the child it names, through a new `addresses`.**
`certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real
signature check by the parent's quorum, and the thing being matched here is a proposal
that quorum has not signed yet -- that is what the rung is waiting for.
`SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d`
tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says
outright that nothing may decide a lineage on it. The three values it reads come out of
`assembleTags` together, so they agree by construction rather than by agreement.
Correctness is not at stake in either: these two fields only decide which progress bar
is drawn and which button is live. Whether the parent actually agreed is `isCertified`,
which does check the signature.
**`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation
that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline.
Three callers now, one of them new, and the no-op repository answers null -- which is
also the honest answer for a ceremony that has not finished, and the reason all three
resolve it per emission rather than once.
**Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug
reinstated rather than only against the fix.** Two assert that the screen follows its
own ceremony's signing when a sibling's is newer. The third is the permanent block, and
it is driven in two emissions on a hot flow for a reason worth recording: written as a
single emission it asserted "still null" and **passed against the bug**, because at
that moment the watcher had not started. Nothing is proved by a field that is null
before anybody has looked at it.
**One test from the commit before this had to be rewritten, and the change is this
commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving
by parking the session watcher inside `pendingApproval` while the key-state watcher
wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the
session collector, after the park -- so the setup could not happen. The claim was
untouched by that, so it is now made against the session watcher versus the room
watcher, which are genuinely concurrent: both collect before either writes, neither
waits on the other, and the field they contend over is `roomHasCeremony`, which is the
one that decides whether the offer to start appears. Re-confirmed failing against the
lost update reinstated.
973 jvm tests, 454 common tests, `m3Audit` meeting every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>