A ChillDKG ritual was invisible to everyone it happened to. Kinds 30310-30316
are routed to ChillDkgRitualManager and never become ChatMessage rows, so a
member's device published a host key and joined a ceremony that fixes the
group's signing quorum for good, with nothing appearing anywhere they would
look. The only way to find out was to open the group's details and press Shared
Key on the off chance. Worse in the flow this is reached through: a group whose
first event is the ceremony now materialises as a room with no messages in it at
all and no explanation of why it appeared.
That matters more than it would for a chat feature, because a ritual cannot
finish until every member's device has taken part. The progress ladder on the
ritual screen exists to show that it is waiting on 2 of 3 -- but nothing told
member 3 they were the one being waited on.
Three milestones now land in the transcript: the ceremony starting, the group
getting a key, and the ceremony being abandoned (with the reason, which names
the culprit participant when ChillDKG identified one).
## Derived locally, not sent
Nothing new goes on the wire. Every member already receives the proposal, and
computes the completion and any failure for themselves, so each device writes
its own row from what it already has. That costs no traffic, needs no new event
kind, and -- the reason it is worth doing this way -- makes it impossible for the
transcript to disagree with the ritual it describes. A "ceremony started" message
that was itself sent could arrive without the proposal, or outlive a session that
never existed on that device.
The rows are written where the state changes: announceStarted() at both places a
DkgSession is created (proposeRitual for the member who opens it, acceptProposal
for everyone else), and announce() at the COMPLETE write and in fail().
They are written once by construction rather than by de-duplication, which is
worth spelling out because ChatMessage.id is autogenerated and a second insert
would simply be a second line. A session is created once, since acceptProposal
returns early when the row exists; advance() leaves a COMPLETE ritual alone; and
fail() now re-reads the session and returns if it is already FAILED. That last
one is also a fix in its own right -- a ritual can be failed from two directions,
a FAILURE message from a member and a fault raised locally, and while the second
write was previously harmless it would now have told the group twice.
## Rendered as a system line, not a bubble
ChatMessage.messageType already carries "message", "artifact", "pendingCommit"
and eleven others, so TYPE_DKG_STARTED / _COMPLETE / _FAILED join it with no
schema change. But the list renders every row as a bubble with the sender's name
and a delivery-status icon, and neither fits: "a shared key ceremony started" is
not something the coordinator said, and a row with no gift wrap behind it would
show the KeyOff "unsealed" icon as though it had failed to send.
RitualNotice renders them across the width instead -- icon, text, timestamp, no
author, no side, no delivery state -- and is tappable through to the ritual
screen, since the point of telling the group is to give them somewhere to go. It
branches out of the items() lambda with an early return so the existing bubble
layout is untouched.
The other informational types ("pendingCommit", "processedCommit",
"proposalStaged", "undecryptableOuterLayer") have the same problem and are
deliberately left alone: how MLS commit rows should read is a separate call from
making the key ceremony visible.
## Not covered by tests
The whole change is Room writes and Compose rendering, neither of which runs
under :composeApp:testDebugUnitTest -- there is no sqlite driver on the JVM test
classpath. Verified by compilation only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>