Files
mantra-kmp/docs/shared-key-ceremony.md

165 lines
8.9 KiB
Markdown
Raw Normal View History

docs: write down the shared-key subsystem and how Marmot membership fails First docs in the repo -- README.md is still the stock KMP template. Three documents plus an index, covering the parts whose behaviour is not recoverable by reading the code: where the reasoning lives in a protocol, where a failure mode is silent, or where a decision looked arbitrary and was not. marmot-membership.md is the one that earns its place. Everything about adding a member compiles, the invite reports success, and a member simply never appears -- and the reason is never in the invite code. It records that inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that ChatRepository does not expose it, so every group invite takes the deferred-welcome path including the first, when the group is still just its creator and the commit has no audience at all. Then why that is silent rather than noisy: MarmotInboundManager refuses future-epoch messages outright, on both wire formats, with no queue and no replay, so a commit arriving before its recipient's welcome is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past epochs and does nothing for messages from ahead. Three options are set out with the per-invite correctness table, including the honest limit that the recommended one narrows the race without closing it. shared-key-derivation.md argues why the paths are not BIP32 -- no chain code exists, hardened derivation is impossible rather than unimplemented, and a FROST tweak takes the scalar as input so the chain code leaves the problem entirely. It records the x-only serialisation trap avoided by choosing the scalar directly, and states the rule that must not be broken: never reconstruct a derived key in the clear, because k = k' - t hands over the group key rather than one derived key. shared-key-ceremony.md covers the seven kinds, the three approval gates and why the coordinator's aggregations are deliberately not among them, faults as values rather than exceptions, and the transcript's idempotency-by-construction. It also writes down the invariant that produces no error when broken: pendingApproval must mirror the gates in advance, or the screen offers an approval that does nothing -- or none while the ritual sits still. Every factual claim was checked against the source rather than recalled, which turned up one correction worth having: there are two future-epoch refusals, for PrivateMessage and for Commit, so the drop covers both wire formats and not just one. Each document leads with the failure mode rather than the architecture, on the grounds that a failure is what sends somebody to docs in the first place, and each lists its known gaps -- including that none of this has run on a physical device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
# The shared key ceremony
docs: record phase 10, and the deferred decision it carries out `docs/subgroups.md` was written as ten phases of reasoning kept in the order they were argued, and phase 4 spent forty lines on why the child's ceremony was *not* held in the parent's Marmot room -- explicitly so the decision would not be re-litigated without its price attached. That section is now a shopping list that has been carried out, so it keeps its argument and gains a pointer forward, and the three costs it enumerated are checked off one by one in a new phase 10. The parts of the note that state the old arrangement as present-tense fact are updated rather than annotated: the three-ceremonies table now reads one room, three ceremonies, two quorums, and says the thing that needs saying twice -- an MLS message reaches the whole tree, so a ceremony in the parent's room has to name who it is with. Phase 10 itself is written the way the others are, around what fails silently: - `DkgSession.chatRoomId` stopped identifying a ceremony, and the place that matters is `completedKey`'s last fallback, which every member welcomed after a group's own ceremony lands on; - `signingPath` had to admit a Marmot room, which widens the one function whose contract is that a path never comes off a proposal; - the p-tags had to stay on both transports, which is the opposite of what `FrostSigningManager` correctly does. The two sections that argued the old collision -- "The collision this buys" and "Why a subgroup cannot be the whole group was withdrawn" -- keep their reasoning and gain the end of it: `(room, parent)` stopped telling two subgroups of one parent apart, so the lookup moved to `(room, parent, admins)`, and the permanent half of the refusal disappeared with the derived room. The limitation and the appendix entry are struck through rather than deleted, since what they were weighing is why the phase exists. `docs/shared-key-ceremony.md` no longer says a ceremony runs over a NIP-17 chat. The participant set is the proposal's p-tags on both transports, and that distinction is the whole reason it is stated that way rather than as "the group". `docs/mls-skipped-keys.md` keeps `proposeRitual` in its table of reliable triggers and now says what changed about it: it reached that table on gift wraps, where the bug does not apply, and a subgroup's ceremony now rides group events. It is the entry with the worst consequence -- a ChillDKG cannot finish until every participant takes part, so one lost round-1 message stalls it permanently for everybody rather than costing one member a line of chat. That is the thing the quartz fix in that note is now load-bearing for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:28:28 +02:00
A group creates a `t`-of-`n` FROST key by running ChillDKG over its own chat. No
trusted dealer, no single device ever holding the whole key.
docs: write down the shared-key subsystem and how Marmot membership fails First docs in the repo -- README.md is still the stock KMP template. Three documents plus an index, covering the parts whose behaviour is not recoverable by reading the code: where the reasoning lives in a protocol, where a failure mode is silent, or where a decision looked arbitrary and was not. marmot-membership.md is the one that earns its place. Everything about adding a member compiles, the invite reports success, and a member simply never appears -- and the reason is never in the invite code. It records that inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that ChatRepository does not expose it, so every group invite takes the deferred-welcome path including the first, when the group is still just its creator and the commit has no audience at all. Then why that is silent rather than noisy: MarmotInboundManager refuses future-epoch messages outright, on both wire formats, with no queue and no replay, so a commit arriving before its recipient's welcome is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past epochs and does nothing for messages from ahead. Three options are set out with the per-invite correctness table, including the honest limit that the recommended one narrows the race without closing it. shared-key-derivation.md argues why the paths are not BIP32 -- no chain code exists, hardened derivation is impossible rather than unimplemented, and a FROST tweak takes the scalar as input so the chain code leaves the problem entirely. It records the x-only serialisation trap avoided by choosing the scalar directly, and states the rule that must not be broken: never reconstruct a derived key in the clear, because k = k' - t hands over the group key rather than one derived key. shared-key-ceremony.md covers the seven kinds, the three approval gates and why the coordinator's aggregations are deliberately not among them, faults as values rather than exceptions, and the transcript's idempotency-by-construction. It also writes down the invariant that produces no error when broken: pendingApproval must mirror the gates in advance, or the screen offers an approval that does nothing -- or none while the ritual sits still. Every factual claim was checked against the source rather than recalled, which turned up one correction worth having: there are two future-epoch refusals, for PrivateMessage and for Commit, so the drop covers both wire formats and not just one. Each document leads with the failure mode rather than the architecture, on the grounds that a failure is what sends somebody to docs in the first place, and each lists its known gaps -- including that none of this has run on a physical device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
Implemented in `ChillDkgRitualManager`, on `fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG`.
## Shape
docs: record phase 10, and the deferred decision it carries out `docs/subgroups.md` was written as ten phases of reasoning kept in the order they were argued, and phase 4 spent forty lines on why the child's ceremony was *not* held in the parent's Marmot room -- explicitly so the decision would not be re-litigated without its price attached. That section is now a shopping list that has been carried out, so it keeps its argument and gains a pointer forward, and the three costs it enumerated are checked off one by one in a new phase 10. The parts of the note that state the old arrangement as present-tense fact are updated rather than annotated: the three-ceremonies table now reads one room, three ceremonies, two quorums, and says the thing that needs saying twice -- an MLS message reaches the whole tree, so a ceremony in the parent's room has to name who it is with. Phase 10 itself is written the way the others are, around what fails silently: - `DkgSession.chatRoomId` stopped identifying a ceremony, and the place that matters is `completedKey`'s last fallback, which every member welcomed after a group's own ceremony lands on; - `signingPath` had to admit a Marmot room, which widens the one function whose contract is that a path never comes off a proposal; - the p-tags had to stay on both transports, which is the opposite of what `FrostSigningManager` correctly does. The two sections that argued the old collision -- "The collision this buys" and "Why a subgroup cannot be the whole group was withdrawn" -- keep their reasoning and gain the end of it: `(room, parent)` stopped telling two subgroups of one parent apart, so the lookup moved to `(room, parent, admins)`, and the permanent half of the refusal disappeared with the derived room. The limitation and the appendix entry are struck through rather than deleted, since what they were weighing is why the phase exists. `docs/shared-key-ceremony.md` no longer says a ceremony runs over a NIP-17 chat. The participant set is the proposal's p-tags on both transports, and that distinction is the whole reason it is stated that way rather than as "the group". `docs/mls-skipped-keys.md` keeps `proposeRitual` in its table of reliable triggers and now says what changed about it: it reached that table on gift wraps, where the bug does not apply, and a subgroup's ceremony now rides group events. It is the entry with the worst consequence -- a ChillDKG cannot finish until every participant takes part, so one lost round-1 message stalls it permanently for everybody rather than costing one member a line of chat. That is the thing the quartz fix in that note is now load-bearing for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:28:28 +02:00
The participant set is who the proposal `p`-tags, the member who opens the
ceremony coordinates it, and every protocol message travels on a pipeline chat
messages already use — a gift-wrapped rumor in a NIP-17 room, an MLS application
message in a Marmot one — so there is no second transport to operate.
A group's own ceremony runs in the NIP-17 room its members share, and there the
participant set and the room's membership are the same thing. A **subgroup's**
runs in its parent's Marmot room over a subset of it, which is why the set is the
p-tags rather than the room: see [subgroups.md](./subgroups.md).
docs: write down the shared-key subsystem and how Marmot membership fails First docs in the repo -- README.md is still the stock KMP template. Three documents plus an index, covering the parts whose behaviour is not recoverable by reading the code: where the reasoning lives in a protocol, where a failure mode is silent, or where a decision looked arbitrary and was not. marmot-membership.md is the one that earns its place. Everything about adding a member compiles, the invite reports success, and a member simply never appears -- and the reason is never in the invite code. It records that inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that ChatRepository does not expose it, so every group invite takes the deferred-welcome path including the first, when the group is still just its creator and the commit has no audience at all. Then why that is silent rather than noisy: MarmotInboundManager refuses future-epoch messages outright, on both wire formats, with no queue and no replay, so a commit arriving before its recipient's welcome is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past epochs and does nothing for messages from ahead. Three options are set out with the per-invite correctness table, including the honest limit that the recommended one narrows the race without closing it. shared-key-derivation.md argues why the paths are not BIP32 -- no chain code exists, hardened derivation is impossible rather than unimplemented, and a FROST tweak takes the scalar as input so the chain code leaves the problem entirely. It records the x-only serialisation trap avoided by choosing the scalar directly, and states the rule that must not be broken: never reconstruct a derived key in the clear, because k = k' - t hands over the group key rather than one derived key. shared-key-ceremony.md covers the seven kinds, the three approval gates and why the coordinator's aggregations are deliberately not among them, faults as values rather than exceptions, and the transcript's idempotency-by-construction. It also writes down the invariant that produces no error when broken: pendingApproval must mirror the gates in advance, or the screen offers an approval that does nothing -- or none while the ritual sits still. Every factual claim was checked against the source rather than recalled, which turned up one correction worth having: there are two future-epoch refusals, for PrivateMessage and for Commit, so the drop covers both wire formats and not just one. Each document leads with the failure mode rather than the architecture, on the grounds that a failure is what sends somebody to docs in the first place, and each lists its known gaps -- including that none of this has run on a physical device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
The coordinator is a participant too, and ChillDKG treats it as untrusted: it
relays and aggregates but cannot learn secrets or bias the key. Being the room's
creator buys it no authority, only work.
| kind | from | carries |
|-------|-------------|------------------------------------------------------|
| 30310 | coordinator | proposal — "let's make a t-of-n key" |
| 30311 | participant | host public key |
| 30312 | participant | `pmsg1`, this device's contribution |
| 30313 | coordinator | `cmsg1`, everyone's contributions combined |
| 30314 | participant | CertEq signature confirming the combined result |
| 30315 | coordinator | `cmsg2`, the success certificate |
| 30316 | anyone | failure — abort and blame |
Every step is a pure function of inputs the device has already stored, so there is
no long-lived in-memory session to lose. Each inbound message is persisted and
then the ritual is asked whether it can move; if the app dies mid-round it resumes
on the next message. `DkgSession` deliberately stores *inputs* — the randomness
and the received messages — rather than protocol state, which is what makes that
work.
**A ceremony cannot finish until every member takes part.** That is unusual for a
chat feature and drives most of the UI: the progress ladder names who it is
waiting on rather than showing a count, because "2 of 3" does not tell anyone whose
door to knock on.
## Nothing publishes without approval
The ritual is driven by arriving messages, which originally meant a relay
delivering an event to a phone in someone's pocket was enough to enrol its owner
in a group's permanent signing quorum. `acceptProposal` published the host key on
arrival; rounds 1 and 2 followed automatically.
It now publishes nothing on this device's behalf until its owner agrees, at three
separate gates:
| step | publishes | why it is its own decision |
|------------|--------------------------|-------------------------------------------------------------------------------|
| `HOST_KEY` | the host public key | joins the ceremony and fixes `n`. Joining then going quiet holds it open for everyone |
| `ROUND_1` | the key contribution | the member's secret material starts shaping a key they must help sign with |
| `ROUND_2` | the CertEq signature | a real check: it is what stops a coordinator substituting a key the members never contributed to |
The coordinator's two aggregations are **not** gated. They relay other members'
already-published messages and disclose nothing of the coordinator's own, so an
approval there would stall the whole group on one person's attention without
protecting anybody.
The member who opens a ceremony is auto-approved for the host key alone —
starting one is already the act of agreeing to be in it — and is still asked for
rounds 1 and 2.
Each gate returns rather than throwing. The ritual is not failing, it is waiting
on a person; everything received stays stored and it resumes on approval.
> **`pendingApproval` must mirror the gates in `advance` exactly.** If they drift,
> the screen offers an approval that does nothing, or offers none while the ritual
> sits still. Neither produces an error.
Approvals are recorded as three nullable timestamps on `DkgSession`, plus
`approvalRequestedThrough` so the chat line asking for each is written once.
## Faults are values, not exceptions
ChillDKG reports a faulty participant in the `ChilldkgFault` field of each result,
because a faulty participant is a normal outcome of a DKG rather than a bug.
This ritual has one response to all of them — the key is unusable, the session
dies, the group is told — so `raiseIfFaulty` turns them into an exception carrying
the culprit and lets them join `advance`'s single failure path. The gain is the
failure text: "ChillDKG round 2 failed: a participant is faulty (participant 3)"
rather than whatever `e.message` happened to hold. On a failed DKG, which
participant to blame is the only actionable thing there is.
## The transcript
Every protocol message becomes a line in the group's chat naming the member whose
device sent it, plus the three that bracket them: started, complete, abandoned.
These rows are **not anybody's words**: no `giftWrapPayloadId`, no event behind
them, nothing sent to say them. Each device writes its own from messages it
already received, so they cost no traffic and cannot disagree with the ritual they
describe. They render as system lines rather than bubbles — attributing "a shared
key ceremony started" to the coordinator would read as something they said.
Wording describes what a step accomplishes, not what it is called. "sent their
contribution to the key" is useful in a group chat; "sent pmsg1" is not, and the
protocol names are on the shared-key screen for anyone who wants them.
**Idempotency is by construction, not de-duplication.** `ChatMessage` has no key
to make a second insert a no-op — its id is autogenerated — while ritual messages
arrive repeatedly: relays redeliver, and `replayStoredMessages` feeds the whole
backlog through `record()` again on every resume. So every announce is guarded by
reading the row it is about to write over. `DkgParticipantMessage` absorbs the
redelivery itself, being keyed on `(sessionId, participantPublicKey, kind)`; a chat
row cannot.
Request lines carry their step in the message type — one type per step, not one
type for all three. The type is the only thing a transcript keeps: a line drawn
days later has no session to ask what was being requested. Sharing one type left
every request wearing the same icon.
Whether a request was answered is read from the transcript rather than the
session: approving is the only thing that causes the step to be published, and
publishing writes an authored line. That keeps a room that has run more than one
ceremony correct, since `ChatMessage` has no session id to disambiguate with.
## Ordering
ChillDKG has no session-params object to agree on out of band. Every step takes
the host public keys and the threshold and hashes them into the session identity,
so **a group that orders its participants differently on different devices does
not get a weaker key — it gets no key.**
Each device derives that order independently by sorting the host keys bytewise,
and orders each round's messages by their sender's host key to match. Sorting is
the only ordering every device can arrive at without being told. Nothing in the
protocol checks this, so `ChillDkgRitualOrderingTest` does.
## After it completes
The group has a threshold public key; each device keeps its own share, restorable
from that member's wallet backup and nobody else's.
feat(marmot): put a # in front of every group's name, and retire (#admins) A device's room list holds two unrelated kinds of room and nothing on a row said which. A NIP-17 room is a conversation between the people in it. A Marmot room is a *group* -- an id its key derives, a membership baked into an MLS tree, admins who can act for it, a signature anyone holding the id can check -- and the two behave differently enough that guessing is a mistake. `"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half. Only the admin room got it; a subgroup got no marker at all, so as soon as a group had one child, half the Marmot rooms on the device were unmarked. It also sorted nowhere near the group it belonged to, and a truncated row drops a trailing suffix first -- so the marker was missing exactly where the list is crowded enough to need it. **The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every member is welcomed with, so a `#` added at display time would be a name this device alone could see. `#Ekklesia` marks both kinds of group room, and marks them at the front. **Three mints, because there are three ways a Marmot room comes into existence.** `MarmotGroupCreation.create` is the funnel for two of them -- the admin room a group opens after its ceremony, and a subgroup -- and normalising there means neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s convenient room, has a random id rather than a derived one, so it has no key state to adopt and no admin set to bake in and does not pass through that funnel; it applies the rule itself. **Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice from the same bare ceremony-room subject, by two callers that never see each other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string, or the subgroup is not called what its parent certified -- and a certificate is a signature over the name, so a verifier comparing them would see a real mismatch. `of` being idempotent is what makes them agree by construction rather than by both sites being kept in step. **The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup is made, not the subgroup -- and prefixing it too produced two identically-named rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits beside `#Translators` (the group it stood up), which is the distinction the `#` exists to draw. Its subject is trimmed, so the bare name and the two normalised ones cannot differ by whitespace. **The `#` is drawn beside the name field, not pushed into its state.** `name` in `SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the convention, because normalising on every keystroke moves the caret out from under somebody halfway through a word. The coordinator still reads the name they are about to get. Four strings lose the old name -- "Create the #admins group" becomes "Create the admin room", and the three about what "the #admins room" will sign with now say "the admin room". Their keys are renamed with them, since the keys in this catalogue are derived from the text. Around twenty comments, two screen previews and seven test fixtures follow. Docs: the ceremony note states the convention and what it replaces, and the subgroups note's name-field section is rewritten -- it had been arguing from the `"${parent.subject} (#admins)"` synthesis that no longer exists. `docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured debugging log, and rewriting it would falsify a record. Three tests. `MarmotGroupNameTest` pins the rule, idempotence included. `MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both the room row this device draws and the group data every other member reads. `SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed event's tags back out of the signing session: the name the parent is asked to sign is the name `MarmotGroupCreation` will give the room. That last one needed the signable-parent fixture to seed host keys, since a ceremony's signer ids are derived from them rather than stored. **Rooms that already exist keep their names.** The name lives in the epoch-0 group context, so renaming one is an MLS commit every member has to process -- a different change from a naming convention, and not made here. 403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
From there the coordinator can create the group's admin room — a Marmot group
whose id is derived from the shared key. See
[shared-key-derivation.md](./shared-key-derivation.md) for how, and
[marmot-membership.md](./marmot-membership.md) for how members are added to it.
It is named `#` and then the group's name — `#Ekklesia` for a group called
Ekklesia. Every Marmot room on a device carries that mark and no NIP-17 room does,
which is the only thing on a room list that says which of the two a row is. The
rule is one function, `MarmotGroupName.of`, applied where a room is minted rather
than where it is drawn: the name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a name added at display time would be a name this
device alone could see. It replaces `Ekklesia (#admins)`, which marked only the
admin room, left every subgroup unmarked, and sorted nowhere near the group it
belonged to.
docs: write down the shared-key subsystem and how Marmot membership fails First docs in the repo -- README.md is still the stock KMP template. Three documents plus an index, covering the parts whose behaviour is not recoverable by reading the code: where the reasoning lives in a protocol, where a failure mode is silent, or where a decision looked arbitrary and was not. marmot-membership.md is the one that earns its place. Everything about adding a member compiles, the invite reports success, and a member simply never appears -- and the reason is never in the invite code. It records that inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that ChatRepository does not expose it, so every group invite takes the deferred-welcome path including the first, when the group is still just its creator and the commit has no audience at all. Then why that is silent rather than noisy: MarmotInboundManager refuses future-epoch messages outright, on both wire formats, with no queue and no replay, so a commit arriving before its recipient's welcome is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past epochs and does nothing for messages from ahead. Three options are set out with the per-invite correctness table, including the honest limit that the recommended one narrows the race without closing it. shared-key-derivation.md argues why the paths are not BIP32 -- no chain code exists, hardened derivation is impossible rather than unimplemented, and a FROST tweak takes the scalar as input so the chain code leaves the problem entirely. It records the x-only serialisation trap avoided by choosing the scalar directly, and states the rule that must not be broken: never reconstruct a derived key in the clear, because k = k' - t hands over the group key rather than one derived key. shared-key-ceremony.md covers the seven kinds, the three approval gates and why the coordinator's aggregations are deliberately not among them, faults as values rather than exceptions, and the transcript's idempotency-by-construction. It also writes down the invariant that produces no error when broken: pendingApproval must mirror the gates in advance, or the screen offers an approval that does nothing -- or none while the ritual sits still. Every factual claim was checked against the source rather than recalled, which turned up one correction worth having: there are two future-epoch refusals, for PrivateMessage and for Commit, so the drop covers both wire formats and not just one. Each document leads with the failure mode rather than the architecture, on the grounds that a failure is what sends somebody to docs in the first place, and each lists its known gaps -- including that none of this has run on a physical device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
## Known gaps
- No ceremony has been run on a physical device.
feat(marmot): put a # in front of every group's name, and retire (#admins) A device's room list holds two unrelated kinds of room and nothing on a row said which. A NIP-17 room is a conversation between the people in it. A Marmot room is a *group* -- an id its key derives, a membership baked into an MLS tree, admins who can act for it, a signature anyone holding the id can check -- and the two behave differently enough that guessing is a mistake. `"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half. Only the admin room got it; a subgroup got no marker at all, so as soon as a group had one child, half the Marmot rooms on the device were unmarked. It also sorted nowhere near the group it belonged to, and a truncated row drops a trailing suffix first -- so the marker was missing exactly where the list is crowded enough to need it. **The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every member is welcomed with, so a `#` added at display time would be a name this device alone could see. `#Ekklesia` marks both kinds of group room, and marks them at the front. **Three mints, because there are three ways a Marmot room comes into existence.** `MarmotGroupCreation.create` is the funnel for two of them -- the admin room a group opens after its ceremony, and a subgroup -- and normalising there means neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s convenient room, has a random id rather than a derived one, so it has no key state to adopt and no admin set to bake in and does not pass through that funnel; it applies the rule itself. **Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice from the same bare ceremony-room subject, by two callers that never see each other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string, or the subgroup is not called what its parent certified -- and a certificate is a signature over the name, so a verifier comparing them would see a real mismatch. `of` being idempotent is what makes them agree by construction rather than by both sites being kept in step. **The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup is made, not the subgroup -- and prefixing it too produced two identically-named rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits beside `#Translators` (the group it stood up), which is the distinction the `#` exists to draw. Its subject is trimmed, so the bare name and the two normalised ones cannot differ by whitespace. **The `#` is drawn beside the name field, not pushed into its state.** `name` in `SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the convention, because normalising on every keystroke moves the caret out from under somebody halfway through a word. The coordinator still reads the name they are about to get. Four strings lose the old name -- "Create the #admins group" becomes "Create the admin room", and the three about what "the #admins room" will sign with now say "the admin room". Their keys are renamed with them, since the keys in this catalogue are derived from the text. Around twenty comments, two screen previews and seven test fixtures follow. Docs: the ceremony note states the convention and what it replaces, and the subgroups note's name-field section is rewritten -- it had been arguing from the `"${parent.subject} (#admins)"` synthesis that no longer exists. `docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured debugging log, and rewriting it would falsify a record. Three tests. `MarmotGroupNameTest` pins the rule, idempotence included. `MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both the room row this device draws and the group data every other member reads. `SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed event's tags back out of the signing session: the name the parent is asked to sign is the name `MarmotGroupCreation` will give the room. That last one needed the signable-parent fixture to seed host keys, since a ceremony's signer ids are derived from them rather than stored. **Rooms that already exist keep their names.** The name lives in the epoch-0 group context, so renaming one is an MLS commit every member has to process -- a different change from a naming convention, and not made here. 403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
- A member invited to the admin room whose Welcome never goes out is
docs: write down the shared-key subsystem and how Marmot membership fails First docs in the repo -- README.md is still the stock KMP template. Three documents plus an index, covering the parts whose behaviour is not recoverable by reading the code: where the reasoning lives in a protocol, where a failure mode is silent, or where a decision looked arbitrary and was not. marmot-membership.md is the one that earns its place. Everything about adding a member compiles, the invite reports success, and a member simply never appears -- and the reason is never in the invite code. It records that inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that ChatRepository does not expose it, so every group invite takes the deferred-welcome path including the first, when the group is still just its creator and the commit has no audience at all. Then why that is silent rather than noisy: MarmotInboundManager refuses future-epoch messages outright, on both wire formats, with no queue and no replay, so a commit arriving before its recipient's welcome is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past epochs and does nothing for messages from ahead. Three options are set out with the per-invite correctness table, including the honest limit that the recommended one narrows the race without closing it. shared-key-derivation.md argues why the paths are not BIP32 -- no chain code exists, hardened derivation is impossible rather than unimplemented, and a FROST tweak takes the scalar as input so the chain code leaves the problem entirely. It records the x-only serialisation trap avoided by choosing the scalar directly, and states the rule that must not be broken: never reconstruct a derived key in the clear, because k = k' - t hands over the group key rather than one derived key. shared-key-ceremony.md covers the seven kinds, the three approval gates and why the coordinator's aggregations are deliberately not among them, faults as values rather than exceptions, and the transcript's idempotency-by-construction. It also writes down the invariant that produces no error when broken: pendingApproval must mirror the gates in advance, or the screen offers an approval that does nothing -- or none while the ritual sits still. Every factual claim was checked against the source rather than recalled, which turned up one correction worth having: there are two future-epoch refusals, for PrivateMessage and for Commit, so the drop covers both wire formats and not just one. Each document leads with the failure mode rather than the architecture, on the grounds that a failure is what sends somebody to docs in the first place, and each lists its known gaps -- including that none of this has run on a physical device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
indistinguishable, from the coordinator's side, from one who joined.
- The ritual's request chat lines are `3n + 4` per ceremony — 19 lines for five
members. Compact, but they dominate a transcript while a ceremony runs.