fix(marmot): a leaver's SelfRemove was committed but never applied

`MlsGroup.saveState()` does not serialize the staged-proposal pool. `stageCommit`
prepares its commit on a CLONE restored from that state, so the clone always
started with an empty pool: `commit()` produced an empty proposal list, the
epoch advanced, and every proposal the commit was called to apply was silently
dropped.

The case that bites is a departing member. MIP-03 makes a departure a
standalone `SelfRemove` PROPOSAL — the leaver cannot evict themselves — so it
sits in the pool until an authorized member commits it. That commit ran, looked
successful, and left the leaver IN THE TREE, still holding the group's keys and
still able to decrypt everything sent after they left.

`stageCommit` now hands the clone the live group's pool. The other `stage*`
entry points are untouched: each of those has a proposal of its own to make,
and folding a peer's pending SelfRemove into an unrelated commit would also
trip MIP-03's no-mixing rule for a non-admin committer.

Also here:

- `MarmotManager.commitPendingProposals` — the commons-level entry point for
  "commit what a peer proposed", returning null when nothing is staged so a
  caller can drive it unconditionally after ingest.
- `MlsGroup.hasPendingProposals` / `MlsGroupManager.hasPendingProposals` — a
  public way to ask whether there is work to commit, where the proposals
  themselves stay module-internal.
- Scenario runner: `restart_client` (rebuilds the manager over the same stores
  and calls `restoreAll`, so nothing may depend on state that only lived in
  memory) and `leave`. `restart-delivery-faults` replays.
- `removed_members` observations are now checked, including the evictor's own
  commit — the actor was the one participant who did not remember doing it.

Two gaps stay open and asserted rather than deleted, so they fail loudly when
fixed: a peer that has the same proposal staged does not apply the commit
carrying it inline (`leaver-removal-secrecy`), and the proposal pool still does
not survive a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-10 12:06:41 +00:00
parent 14b02a1ebb
commit 5f36618767
9 changed files with 783 additions and 20 deletions
@@ -1592,6 +1592,29 @@ class MarmotManager(
}.event
}
/**
* Commit whatever proposals are staged for this group, if any.
*
* The case that matters is a departing member's standalone `SelfRemove`
* (MIP-03): the leaver cannot evict themselves — a proposal advances
* nothing — so it sits in the pool until an authorized member commits it.
* Until that happens the leaver is STILL IN THE TREE and still able to
* decrypt everything the group sends, which is the opposite of what
* leaving is for.
*
* Returns null when there is nothing staged, so a caller can drive this
* unconditionally after ingest without checking first.
*/
suspend fun commitPendingProposals(
nostrGroupId: HexKey,
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
): OutboundGroupEvent? {
if (!groupManager.hasPendingProposals(nostrGroupId)) return null
return commitAndPublish(nostrGroupId, relays) {
groupManager.stageCommit(nostrGroupId)
}.event
}
/**
* Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as
* `disbanded` in a Commit every member replays.
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* What happens to a member who leaves.
*
* MIP-03 makes a departure a standalone `SelfRemove` PROPOSAL: the leaver
* cannot evict themselves, because a proposal advances nothing. Until an
* authorized member commits it, the leaver is STILL IN THE TREE — which means
* still holding the group's keys and still able to decrypt everything sent
* after they left. That is the opposite of what leaving is for, so the commit
* is not a nicety; it is the point.
*/
class MarmotLeaveProposalTest {
private val nostrGroupId = "1".repeat(64)
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val manager =
MarmotManager(
signer,
SnapshotStateStore(),
SnapshotMessageStore(),
SnapshotBundleStore(),
publisher = ACCEPTING_RELAY,
)
}
@Test
fun `an admin commits a departing member's SelfRemove and the tree shrinks`() =
runBlocking {
val alice = Fixture()
val bob = Fixture()
alice.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("departures", ""),
)
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (commit, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
alice.manager.ingest(commit.signedEvent)
assertEquals(2, alice.manager.memberCount(nostrGroupId))
// Bob departs. The proposal is all he can produce.
val proposal = bob.manager.leaveGroup(nostrGroupId)
val staged = alice.manager.ingest(proposal.signedEvent)
assertIs<MarmotIngestResult.ProposalStaged>(
staged,
"a peer's SelfRemove must reach the pending pool, not be dropped",
)
assertTrue(
alice.manager.groupManager.hasPendingProposals(nostrGroupId),
"the staged proposal must be visible as pending work",
)
assertTrue(
alice.manager.commitPendingProposals(nostrGroupId, emptyList()) != null,
"there was pending work, so a commit must have been produced",
)
assertEquals(
1,
alice.manager.memberCount(nostrGroupId),
"committing a SelfRemove must actually evict the leaver — otherwise they keep " +
"the group's keys and keep reading everything sent after they left",
)
}
}
@@ -107,6 +107,7 @@ class MarmotScenarioRunner(
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val bundleStore = SnapshotBundleStore()
lateinit var manager: MarmotManager
/** Delivered but not yet processed — `tick` is what processes. */
@@ -118,6 +119,9 @@ class MarmotScenarioRunner(
/** Members this client watched join, by pubkey, since the last `clear_events`. */
val sawJoin = mutableListOf<HexKey>()
/** Members this client watched leave, by pubkey, since the last `clear_events`. */
val sawLeave = mutableListOf<HexKey>()
}
/**
@@ -156,25 +160,33 @@ class MarmotScenarioRunner(
return accepted
}
/**
* A manager over this client's stores.
*
* Built through a function rather than inline so `restart_client` can make
* a SECOND one over the SAME stores — which is exactly what a restart is:
* every in-memory ratchet, retained epoch and pending pool is gone, and
* whatever the client still knows has to come back off durable state.
*/
private fun buildManager(client: VectorClient) =
MarmotManager(
client.signer,
client.mlsStore,
client.messageStore,
client.bundleStore,
publisher =
MarmotPublisher { event, _ ->
val label = peekLabel(client.name)
val accepted = nextOutcome(client.name)
if (accepted) inFlight.add(Queued(client.name, event, COMMIT_CLASS, label))
accepted
},
)
suspend fun run() {
vector.unmodelledOutcomes.firstOrNull()?.let { throw UnsupportedScenarioOutcome(it.type) }
preScanPublishOutcomes()
clients.values.forEach { client ->
client.manager =
MarmotManager(
client.signer,
client.mlsStore,
client.messageStore,
SnapshotBundleStore(),
publisher =
MarmotPublisher { event, _ ->
val label = peekLabel(client.name)
val accepted = nextOutcome(client.name)
if (accepted) inFlight.add(Queued(client.name, event, COMMIT_CLASS, label))
accepted
},
)
}
clients.values.forEach { client -> client.manager = buildManager(client) }
vector.steps.forEach { step -> execute(step) }
verify()
@@ -197,6 +209,8 @@ class MarmotScenarioRunner(
"reorder_messages" -> reorderMessages(step)
"withhold_message" -> withholdMessage(step)
"release_withheld" -> releaseWithheld(step)
"restart_client" -> restartClient(step)
"leave" -> leave(step)
// The publication's outcome was consumed when it was made; the step
// itself carries no further state change.
"acknowledge_outbound" -> Unit
@@ -230,6 +244,7 @@ class MarmotScenarioRunner(
step.strings("clients").ifEmpty { vector.clients }.forEach {
client(it).received.clear()
client(it).sawJoin.clear()
client(it).sawLeave.clear()
}
}
@@ -370,6 +385,11 @@ class MarmotScenarioRunner(
"Remove at a time and the vector's single acknowledgement would not line up"
}
remover.manager.removeMember(groupId, leaves.single(), emptyList())
// The evictor watched this departure too. Only ticks diff membership,
// and an eviction the client commits itself never passes through one —
// so without this the actor is the one participant who does not
// remember doing it.
remover.sawLeave.addAll(targets)
}
/**
@@ -450,6 +470,41 @@ class MarmotScenarioRunner(
inFlight.addAll(held)
}
/**
* Drop the client's process and bring it back over the same stores.
*
* The point is what does NOT survive: the ratchet position, the retained
* epoch window, any staged commit. A client that reads the same traffic
* correctly only because it kept those in memory is not durable, and the
* fault vectors pair a restart with a replayed queue to catch exactly
* that.
*/
private suspend fun restartClient(step: ScenarioVector.Step) {
val client = client(step.string("client") ?: error("restart_client without a client"))
client.manager = buildManager(client)
// A fresh manager knows nothing until it reads its stores — the same
// call `Account` makes at startup. Skipping it would model a client
// that lost its groups, not one that restarted.
client.manager.restoreAll()
}
/**
* A member departs: a standalone SelfRemove PROPOSAL, not a commit.
*
* The leaver does not advance the group — another authorized member
* commits the proposal — so this queues the proposal for delivery and
* nothing else. It deliberately does not consume a publication outcome:
* the vectors never acknowledge a leave, because there is no commit to
* acknowledge.
*/
private suspend fun leave(step: ScenarioVector.Step) {
val who = client(step.string("client") ?: error("leave without a client"))
val groupId = who.groups[currentGroup] ?: error("${who.name} left a group it is not in")
val proposal = who.manager.leaveGroup(groupId)
inFlight.add(Queued(who.name, proposal.signedEvent, PROPOSAL_CLASS, null))
who.groups.remove(currentGroup)
}
private suspend fun sendAppMessage(step: ScenarioVector.Step) {
val sender = client(step.string("sender") ?: error("send_app_message without a sender"))
val groupId = sender.groups[currentGroup] ?: error("${sender.name} sent before joining a group")
@@ -499,8 +554,29 @@ class MarmotScenarioRunner(
}
}
// A standalone proposal advances nothing on its own. The reference
// commits what it staged as part of processing, and so must we — a
// SelfRemove nobody commits leaves the departing member in the tree,
// still reading the group. Only an admin may do it; a non-admin's
// commit would be rejected by every peer.
client.groups.values.forEach { groupId ->
val admins =
client.manager
.groupView(groupId)
?.adminPubkeys
.orEmpty()
if (client.signer.pubKey in admins) {
runCatching { client.manager.commitPendingProposals(groupId, emptyList()) }
}
}
before.forEach { (groupId, was) ->
client.sawJoin.addAll(membersOf(client, groupId) - was)
val now = membersOf(client, groupId)
client.sawJoin.addAll(now - was)
// A client evicted from the group reads an empty roster, which
// would otherwise look like watching everybody leave at once. It
// did not watch anything: it lost the group.
if (now.isNotEmpty()) client.sawLeave.addAll(was - now)
}
}
@@ -580,11 +656,17 @@ class MarmotScenarioRunner(
if (got != want) failures.add("${expected.client} received $got, expected $want")
}
expected.addedMembers?.let { want ->
val got = client.sawJoin.mapNotNull { pubkey -> clients.values.firstOrNull { it.signer.pubKey == pubkey }?.name }
val got = names(client.sawJoin)
if (got.sorted() != want.sorted()) {
failures.add("${expected.client} saw $got join, expected $want")
}
}
expected.removedMembers?.let { want ->
val got = names(client.sawLeave)
if (got.sorted() != want.sorted()) {
failures.add("${expected.client} saw $got leave, expected $want")
}
}
}
check(failures.isEmpty()) {
"vector ${vector.name} diverged from its expected trace:\n " + failures.joinToString("\n ")
@@ -593,12 +675,16 @@ class MarmotScenarioRunner(
private fun client(name: String) = clients[name] ?: error("vector names a client '$name' that its roster does not list")
/** Client names for a list of pubkeys; the vectors talk about people. */
private fun names(pubkeys: List<HexKey>) = pubkeys.mapNotNull { key -> clients.values.firstOrNull { it.signer.pubKey == key }?.name }
private companion object {
const val CHAT_KIND = 9
/** The two message classes a fault selector distinguishes. */
const val APPLICATION_CLASS = "application"
const val COMMIT_CLASS = "commit"
const val PROPOSAL_CLASS = "proposal"
/** The label for a vector that never says `in_group` — most of them. */
const val DEFAULT_GROUP = "default"
@@ -117,6 +117,41 @@ class MarmotScenarioVectorTest {
@Test
fun readdAfterEviction() = replay("readd-after-eviction.v1.json")
/**
* A restart in the middle of a replayed, duplicated, reordered queue.
* Nothing may depend on state that only lived in memory.
*/
@Test
fun restartDeliveryFaults() = replay("restart-delivery-faults.v1.json")
/**
* `leaver-removal-secrecy` gets most of the way and stops at a narrower
* bug than the one it found.
*
* It surfaced that a departing member's `SelfRemove` was staged, committed,
* and then NOT applied — `MlsGroup.saveState()` does not carry the
* staged-proposal pool, so the staging clone committed an empty proposal
* list, advanced the epoch, and left the leaver in the tree with the keys.
* That is fixed (see `MarmotLeaveProposalTest`), and the committer now
* reaches the expected epoch and membership.
*
* What remains: a PEER that has the same proposal staged does not apply the
* commit carrying it inline — bob stays an epoch behind and cannot read
* what follows. Asserted rather than deleted so it stays visible; the day
* that is fixed this test fails and the vector moves up to [replay].
*/
@Test
fun aPeerWithTheSameProposalStagedStillMissesTheCommit() {
val thrown =
assertFailsWith<IllegalStateException> {
runBlocking { MarmotScenarioRunner(load("leaver-removal-secrecy.v1.json")).run() }
}
assertTrue(
thrown.message.orEmpty().contains("bob[default] epoch 2, expected 3"),
"the remaining divergence must still be the peer left behind: ${thrown.message}",
)
}
/**
* `convergence-committer-selected` concludes with a `convergence_decision`
* — which tip the client picked, under which rule, and whether the witness
@@ -189,6 +224,8 @@ class MarmotScenarioVectorTest {
"queue-faults.v1.json",
"delayed-past-epoch-app-message.v1.json",
"readd-after-eviction.v1.json",
"restart-delivery-faults.v1.json",
"leaver-removal-secrecy.v1.json",
)
}
}
@@ -106,6 +106,12 @@ class ScenarioVector(
val addedMembers: List<String>? = null,
/** The group description the vector states, when it states one. */
val groupDescription: String? = null,
/**
* Members this client must have seen LEAVE, by client name. Same
* null-vs-empty distinction as [addedMembers]: an empty list is the
* assertion that nobody left.
*/
val removedMembers: List<String>? = null,
)
/**
@@ -236,6 +242,9 @@ class ScenarioVector(
addedMembers =
(obj["added_members"] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content },
removedMembers =
(obj["removed_members"] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content },
)
}
}
@@ -0,0 +1,349 @@
{
"scenario_name": "leaver-removal-secrecy/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "leaver-removal-secrecy/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "leaver",
"invitees": [
"bob",
"carol",
"dave"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "leaver:pre:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "leaver:pre:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "leaver:pre:carol"
},
{
"type": "send_app_message",
"sender": "dave",
"payload": "leaver:pre:dave"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "remove_members",
"remover": "alice",
"members": [
"dave"
],
"pending": "remove-dave"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "remove-dave",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "leaver:gap:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "leaver:gap:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "leaver:gap:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "leave",
"client": "carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice"
]
},
{
"type": "acknowledge_outbound",
"client": "alice",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "leaver:tail:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "leaver:tail:bob"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "leaver:gap:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "leaver:gap:bob",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "leaver:gap:carol",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "leaver:tail:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "leaver:tail:bob",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "carol",
"payload": "leaver:tail:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "carol",
"payload": "leaver:tail:bob",
"count": 0
}
}
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 12,
"client": "alice",
"pending": "remove-dave",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 3,
"member_count": 2,
"received_payloads": [
"leaver:pre:bob",
"leaver:pre:carol",
"leaver:pre:dave",
"leaver:gap:bob",
"leaver:gap:carol",
"leaver:tail:bob"
],
"removed_members": [
"dave",
"carol"
]
},
{
"type": "client_state",
"client": "bob",
"epoch": 3,
"member_count": 2,
"received_payloads": [
"leaver:pre:alice",
"leaver:pre:carol",
"leaver:pre:dave",
"leaver:gap:alice",
"leaver:gap:carol",
"leaver:tail:alice"
],
"removed_members": [
"dave",
"carol"
]
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob"
],
"epoch": 3,
"member_count": 2
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob"
]
}
]
}
@@ -0,0 +1,114 @@
{
"scenario_name": "restart-delivery-faults/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "restart-delivery-faults/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "restart-delivery-faults",
"invitees": [
"bob",
"carol"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:restart-delivery"
},
{
"type": "withhold_message",
"selector": { "sender": "bob", "class": "application" },
"label": "restart-delayed"
},
{
"type": "restart_client",
"client": "alice"
},
{
"type": "release_withheld",
"label": "restart-delayed"
},
{
"type": "duplicate_message",
"selector": { "sender": "bob", "class": "application" }
},
{
"type": "reorder_messages",
"order": [
{ "sender": "bob", "class": "application", "occurrence": 1 },
{ "sender": "bob", "class": "application" }
]
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice"
]
},
{
"type": "observe",
"clients": [
"alice"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 1,
"member_count": 3,
"received_payloads": [
"bob:restart-delivery"
]
}
]
}
@@ -176,6 +176,32 @@ class MlsGroup private constructor(
*/
internal fun pendingProposalsSnapshot(): List<PendingProposal> = pendingProposals.toList()
/**
* Whether any proposal is staged and waiting for a Commit.
*
* Public where [pendingProposalsSnapshot] is internal: a caller outside
* this module has no business reading the proposals, but it does need to
* know there is work to commit. A standalone `SelfRemove` from a departing
* member sits here until an authorized member commits it — and until then
* the leaver is still in the tree and still reading the group.
*/
fun hasPendingProposals(): Boolean = pendingProposals.isNotEmpty()
/**
* Replace this group's staged-proposal pool with [proposals].
*
* Exists for one reason: [saveState] does NOT serialize the pool, so a
* clone made for staging a commit starts empty, and `commit()` on it
* produces an EMPTY commit — the epoch advances and every proposal the
* commit was meant to apply is silently dropped. A departing member's
* `SelfRemove` is the case that bites: the group looks like it processed
* the departure, and the leaver is still in the tree holding the keys.
*/
internal fun adoptPendingProposals(proposals: List<PendingProposal>) {
pendingProposals.clear()
pendingProposals.addAll(proposals)
}
/**
* The GroupContext extension list as it stands. Test-only: callers
* that want the dictionary should use [appDataDictionary], which
@@ -468,8 +468,28 @@ class MlsGroupManager(
}
}
/** Stage a self-update / empty Commit. See [StagedCommit]. */
suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit = stage(nostrGroupId) { it.commit() }
/** Whether [nostrGroupId] has a staged proposal waiting for a Commit. */
fun hasPendingProposals(nostrGroupId: HexKey): Boolean = groups[nostrGroupId]?.hasPendingProposals() == true
/**
* Stage a Commit over whatever proposals are already staged.
*
* Unlike every other `stage*` entry point this one has nothing of its own
* to propose — the proposals are already in the LIVE group's pool, put
* there by ingesting a peer's standalone proposal. [MlsGroup.saveState]
* does not carry that pool, so the clone must be handed it explicitly;
* without that this commits an empty proposal list, advances the epoch,
* and drops the very proposal it was called to apply.
*/
suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit =
mutex.withLock {
val live = requireGroup(nostrGroupId)
val priorState = live.saveState()
val clone = MlsGroup.restore(priorState)
clone.adoptPendingProposals(live.pendingProposalsSnapshot())
val result = clone.commit()
StagedCommit(result, priorState, clone.saveState())
}
/**
* Process a received Commit, advancing the epoch.