feat(marmot): disband a group, edit its avatar link, and replay seven more vectors

Disband (`marmot.group.lifecycle.v1`, 0x800c)
--------------------------------------------
We enforced the Disbanded state but nothing on this side could enter it, so
the enforcement was only reachable from a peer's commit. `disbandGroup` writes
the component in a Commit every member replays. It refuses what is
structurally impossible — a non-admin, a legacy MIP-01 group with no carrier
for a lifecycle state, a second disband — and, unlike every other metadata
setter, refuses to report success when no relay acknowledged the commit: the
caller is about to tell a human the conversation is over, and a group that is
still live for everyone else must not be announced as ended. The obligation
stays queued either way.

On Android it is an admin-only action in the group header behind its own
confirmation, worded for what it is: for everyone, and not reopenable.

Avatar link (`marmot.group.avatar-url.v1`, 0x8007)
--------------------------------------------------
`setGroupAvatarUrl` existed but only `amy` could reach it — the renderer
already preferred a URL avatar over the Blossom image, and no Android screen
could set one. Edit Group Info now carries the field; it commits separately
from the profile so saving a rename does not rewrite the avatar state, and
clearing it falls the group back to the uploaded image.

Seven more scenario vectors
---------------------------
New steps: `update_group_data`, `remove_members`, and the delivery-fault
family — `omit_message`, `duplicate_message`, `reorder_messages`,
`withhold_message`, `release_withheld`. Selectors are matched key by key and
an unknown key is refused, because a silently widened selector injects a
different fault from the scripted one while still reporting under the
vector's name. `group_profile` outcomes are checked too.

Replaying now: group-data-update, deferred-tick-catchup, incremental-growth,
drop-queued, queue-faults, delayed-past-epoch-app-message,
readd-after-eviction. 18 vector tests, all green.

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 11:50:18 +00:00
parent 92f1fe3422
commit 14b02a1ebb
19 changed files with 1865 additions and 17 deletions
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
@@ -553,6 +555,54 @@ class AccountMarmotActions(
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Disband a Marmot MLS group (`marmot.group.lifecycle.v1`, `0x800c`).
*
* Irreversible and absorbing: every member's copy terminalizes when they
* apply the commit, and there is no commit that walks it back. The caller
* MUST have confirmed with a human first — this layer only refuses what is
* structurally impossible (a non-admin, a legacy group, a second disband),
* which is not the same as asking.
*
* Deliberately NOT silent on failure the way the other actions here are: a
* disband that did not happen must not look like one that did, so the
* exception propagates to the caller's error path.
*/
suspend fun disbandMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.disbandGroup(nostrGroupId, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Set or clear the group's plain-`https` avatar link
* (`marmot.group.avatar-url.v1`, `0x8007`).
*
* The lightweight avatar carrier: no Blossom upload, no key material, just
* a URL every Marmot client can render. A blank [url] clears it, which
* falls the group back to its encrypted Blossom image if it has one — the
* two carriers coexist and this one wins while it is set.
*/
suspend fun setMarmotGroupAvatarUrl(
nostrGroupId: HexKey,
url: String,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val avatar = url.trim().takeIf { it.isNotEmpty() }?.let { GroupAvatarUrlV1(MarmotWebUrl.normalize(it, label = "avatar URL")) }
manager.setGroupAvatarUrl(nostrGroupId, avatar, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
@@ -2521,6 +2521,24 @@ class AccountViewModel(
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
}
/**
* Disband the group for everyone. Irreversible — the caller is responsible
* for confirming with the user before this is reached.
*/
suspend fun disbandMarmotGroup(nostrGroupId: String) {
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.disbandMarmotGroup(nostrGroupId, relays)
}
/** Set (or, with a blank string, clear) the group's plain-https avatar link. */
suspend fun setMarmotGroupAvatarUrl(
nostrGroupId: String,
url: String,
) {
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.setMarmotGroupAvatarUrl(nostrGroupId, url, relays)
}
suspend fun resetMarmotState() {
account.marmot.resetMarmotState()
}
@@ -43,6 +43,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_footer
import com.vitorpamplona.amethyst.commons.resources.marmot_avatar_url_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_edit_info_footer
import com.vitorpamplona.amethyst.commons.resources.marmot_group_description_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_group_name
@@ -71,9 +74,11 @@ fun EditGroupInfoScreen(
val currentName by chatroom.displayName.collectAsStateWithLifecycle()
val currentDescription by chatroom.description.collectAsStateWithLifecycle()
val currentImage by chatroom.image.collectAsStateWithLifecycle()
val currentAvatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
var name by remember(currentName) { mutableStateOf(currentName ?: "") }
var description by remember(currentDescription) { mutableStateOf(currentDescription ?: "") }
var avatarUrl by remember(currentAvatarUrl) { mutableStateOf(currentAvatarUrl?.url.orEmpty()) }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
var removeIcon by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
@@ -81,7 +86,12 @@ fun EditGroupInfoScreen(
val context = LocalContext.current
val iconChanged = pickedIcon != null || removeIcon
val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "") || iconChanged
val avatarUrlChanged = avatarUrl.trim() != currentAvatarUrl?.url.orEmpty()
val hasChanges =
name != (currentName ?: "") ||
description != (currentDescription ?: "") ||
iconChanged ||
avatarUrlChanged
Scaffold(
topBar = {
@@ -104,6 +114,13 @@ fun EditGroupInfoScreen(
description = description.trim(),
icon = iconChange,
)
// A separate component (`0x8007`) and therefore a
// separate commit — only made when it actually
// changed, so saving a rename does not also
// rewrite the avatar state.
if (avatarUrlChanged) {
accountViewModel.setMarmotGroupAvatarUrl(nostrGroupId, avatarUrl.trim())
}
launch(Dispatchers.Main) {
Toast
.makeText(context, stringRes(context, R.string.marmot_group_info_updated), Toast.LENGTH_SHORT)
@@ -179,6 +196,23 @@ fun EditGroupInfoScreen(
enabled = !isSaving,
)
Spacer(modifier = Modifier.height(16.dp))
// The plain-https avatar carrier. It wins over the uploaded
// Blossom image while it is set, and clearing it falls the group
// back to that image — so the two fields are not alternatives to
// choose between, they stack.
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text(stringRes(Res.string.marmot_avatar_url)) },
placeholder = { Text(stringRes(Res.string.marmot_avatar_url_placeholder)) },
supportingText = { Text(stringRes(Res.string.marmot_avatar_url_footer)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isSaving,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -75,6 +75,9 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_add_member
import com.vitorpamplona.amethyst.commons.resources.marmot_add_member_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_add_to_group
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_action
import com.vitorpamplona.amethyst.commons.resources.marmot_disband_group_confirm
import com.vitorpamplona.amethyst.commons.resources.marmot_edit_group_info
import com.vitorpamplona.amethyst.commons.resources.marmot_grant
import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_confirm
@@ -142,7 +145,9 @@ fun MarmotGroupInfoScreen(
val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle()
var showLeaveDialog by remember { mutableStateOf(false) }
var showDisbandDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
var isDisbanding by remember { mutableStateOf(false) }
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToPromote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToDemote by remember { mutableStateOf<GroupMemberInfo?>(null) }
@@ -182,9 +187,25 @@ fun MarmotGroupInfoScreen(
contentDescription = stringRes(Res.string.marmot_edit_group_info),
)
}
// Disband ends the conversation for EVERYONE, so only an
// admin sees it and it sits behind its own confirmation.
// Peers reject a non-admin's lifecycle commit anyway; not
// offering it is what keeps a member from trying.
if (myPubkey in adminPubkeys) {
IconButton(
onClick = { showDisbandDialog = true },
enabled = !isLeaving && !isDisbanding,
) {
Icon(
symbol = MaterialSymbols.DeleteForever,
contentDescription = stringRes(Res.string.marmot_disband_group),
tint = MaterialTheme.colorScheme.error,
)
}
}
IconButton(
onClick = { showLeaveDialog = true },
enabled = !isLeaving,
enabled = !isLeaving && !isDisbanding,
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ExitToApp,
@@ -388,6 +409,41 @@ fun MarmotGroupInfoScreen(
)
}
if (showDisbandDialog) {
DisbandGroupDialog(
groupName = displayName ?: stringRes(Res.string.marmot_this_group),
onConfirm = {
showDisbandDialog = false
isDisbanding = true
scope.launch(Dispatchers.IO) {
try {
accountViewModel.disbandMarmotGroup(nostrGroupId)
launch(Dispatchers.Main) {
Toast
.makeText(context, stringRes(context, R.string.marmot_group_disbanded_toast), Toast.LENGTH_SHORT)
.show()
}
nav.nav(Route.Message)
} catch (e: Exception) {
// A disband that reached no relay leaves the group
// live, so the screen must stay usable rather than
// navigate away on a change that did not happen.
isDisbanding = false
launch(Dispatchers.Main) {
Toast
.makeText(
context,
stringRes(context, R.string.marmot_failed_to_disband, e.message),
Toast.LENGTH_LONG,
).show()
}
}
}
},
onDismiss = { showDisbandDialog = false },
)
}
memberToRemove?.let { member ->
ConfirmRemoveMemberDialog(
memberPubkey = member.pubkey,
@@ -650,6 +706,38 @@ fun LeaveGroupDialog(
)
}
/**
* Confirmation for the one group action that cannot be undone.
*
* Disband is absorbing: every member's copy terminalizes and no commit walks it
* back, so the wording says "for everyone" and "cannot be reopened" rather than
* the usual "are you sure".
*/
@Composable
fun DisbandGroupDialog(
groupName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.marmot_disband_group)) },
text = {
Text(stringRes(Res.string.marmot_disband_group_confirm, groupName))
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringRes(Res.string.marmot_disband_group_action), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@Composable
private fun ConfirmRemoveMemberDialog(
memberPubkey: HexKey,
+2
View File
@@ -2696,6 +2696,8 @@
<string name="marmot_failed_to_update">Failed to update: %1$s</string>
<string name="marmot_failed_to_create_group">Failed to create group: %1$s</string>
<string name="marmot_failed_to_leave_group">Failed to leave group: %1$s</string>
<string name="marmot_group_disbanded_toast">Group disbanded</string>
<string name="marmot_failed_to_disband">Could not disband the group: %1$s</string>
<string name="marmot_adding_user">Adding %1$s…</string>
<string name="marmot_failed_to_add_user">Failed to add %1$s: %2$s</string>
<string name="marmot_unknown_error">unknown error</string>
@@ -2638,6 +2638,12 @@
<string name="marmot_retention_1w">1 week</string>
<string name="marmot_retention_active">Messages disappear after %1$s</string>
<string name="marmot_retention_footer">Messages are deleted from every member\'s device after this long. It cannot be changed later.</string>
<string name="marmot_disband_group">Disband group</string>
<string name="marmot_disband_group_confirm">Disband "%1$s" for everyone? The conversation ends for every member and cannot be reopened — a new group would have to be created.</string>
<string name="marmot_disband_group_action">Disband</string>
<string name="marmot_avatar_url">Avatar link</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">An https link every member can load. Leave it empty to use the uploaded image instead.</string>
<string name="marmot_group_default_name">Marmot Group</string>
<string name="marmot_group_fallback_name">Group %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
@@ -1591,6 +1592,69 @@ class MarmotManager(
}.event
}
/**
* Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as
* `disbanded` in a Commit every member replays.
*
* Disband is ABSORBING and irreversible. There is no un-disband commit and
* no later branch that supersedes it — a replacement conversation is a new
* MLS group with a new id. So this is deliberately the only writer of that
* component, it refuses to run twice, and the caller is expected to have
* confirmed with a human first.
*
* Only an admin may do it. The check is local *as well as* remote: peers
* reject a non-admin's lifecycle change anyway, but a non-admin who got
* this far would burn an epoch and desync themselves for a commit nobody
* applies, which is a worse failure than an exception.
*
* Current profile only — MIP-01's `0xF2EE` blob has no lifecycle field, so
* a legacy group genuinely cannot express "disbanded" and this refuses
* rather than writing the state somewhere no peer reads.
*
* The commit takes the normal publish-before-apply path, so a disband that
* no relay acknowledged does not terminalize the group locally either —
* exactly the outcome we want, since a locally-disbanded group nobody else
* heard about would be unreachable state.
*/
suspend fun disbandGroup(
nostrGroupId: HexKey,
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
): OutboundGroupEvent {
val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId")
check(view.isCurrentProfile) {
"Group $nostrGroupId is a legacy MIP-01 group and has no carrier for a lifecycle state"
}
check(signer.pubKey in view.adminPubkeys) {
"Only an admin of group $nostrGroupId can disband it"
}
check(groupManager.getGroup(nostrGroupId)?.currentGroupState()?.isDisbanded != true) {
"Group $nostrGroupId is already disbanded"
}
// requireOutboundAllowed also refuses an Unrecoverable group, which is
// the point: disbanding from state we do not trust would publish a
// terminal commit off a fork.
requireOutboundAllowed(nostrGroupId, "disband the group")
val publication =
commitAndPublish(nostrGroupId, relays) {
groupManager.stageAppDataUpdate(
nostrGroupId,
GroupLifecycleV1.COMPONENT_ID,
GroupLifecycleV1.DISBANDED.encode(),
)
}
// Every other setter is content to leave an unacknowledged commit as a
// retryable obligation and say nothing, because a later retry lands the
// same state. This one cannot: the caller is about to tell a human the
// conversation is over, and a group that is still live for everyone
// else must not be reported as ended. The obligation IS still queued —
// the message says so — but the answer to "did it happen" is no.
check(publication.confirmed) {
"Disband of group $nostrGroupId reached no relay; it stays queued as a pending " +
"publish and the group is still live until one acknowledges it"
}
return publication.event
}
/**
* Set or clear the group avatar, writing to whichever carrier the group uses.
*
@@ -0,0 +1,158 @@
/*
* 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.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
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.assertFailsWith
import kotlin.test.assertTrue
/**
* Disbanding a group — `marmot.group.lifecycle.v1`, component `0x800c`.
*
* We already REFUSED work in a disbanded group; nothing could put a group into
* that state from this side, so the enforcement was only ever reachable from a
* peer's commit. These cover the initiator half.
*
* Disband is absorbing: there is no un-disband, no later branch supersedes it,
* and a replacement conversation is a new MLS group. So the guards matter more
* than the happy path — a mistaken disband cannot be undone, and one published
* off unpublished or untrusted state would terminalize the group for everyone
* on a commit its author never confirmed.
*/
class MarmotDisbandTest {
private val nostrGroupId = "d".repeat(64)
private class Fixture(
publisher: MarmotPublisher = ACCEPTING_RELAY,
) {
val signer = NostrSignerInternal(KeyPair())
val manager =
MarmotManager(
signer,
SnapshotStateStore(),
SnapshotMessageStore(),
SnapshotBundleStore(),
publisher = publisher,
)
}
private suspend fun Fixture.createCurrentProfile() =
manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
profile = GroupProfileV1("doomed", ""),
)
@Test
fun `an admin disbands the group and everything after is refused`() =
runBlocking {
val f = Fixture()
f.createCurrentProfile()
f.manager.disbandGroup(nostrGroupId)
assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded == true)
assertEquals(GroupLifecycleState.DISBANDED, f.manager.lifecycle(nostrGroupId))
// The whole point of the state: outbound work stops.
assertFailsWith<IllegalStateException> {
f.manager.buildTextMessage(nostrGroupId, "anyone still here?")
}
Unit
}
@Test
fun `disband is absorbing and cannot be issued twice`() =
runBlocking {
val f = Fixture()
f.createCurrentProfile()
f.manager.disbandGroup(nostrGroupId)
val thrown = assertFailsWith<IllegalStateException> { f.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("already disbanded"))
}
@Test
fun `a non-admin member cannot disband`() =
runBlocking {
// Two clients: the creator is the admin, the invitee is not. Peers
// would reject a lifecycle change from a non-admin anyway; refusing
// locally is what stops the invitee burning an epoch on a commit
// nobody will apply.
val alice = Fixture()
val bob = Fixture()
alice.createCurrentProfile()
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
bob.manager.ingest(welcome!!.giftWrapEvent)
val thrown = assertFailsWith<IllegalStateException> { bob.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("Only an admin"))
}
@Test
fun `a legacy group has no carrier for a lifecycle state`() =
runBlocking {
val f = Fixture()
f.manager.createGroup(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "legacy",
relays = listOf("wss://relay.invalid"),
),
)
val thrown = assertFailsWith<IllegalStateException> { f.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("legacy"))
}
@Test
fun `a disband no relay accepted does not terminalize the group locally`() =
runBlocking {
// Publish-before-apply, on the one commit that cannot be walked
// back: a group disbanded here but nowhere else would be dead for
// us and alive for everyone. The obligation stays queued, the local
// group stays live, and the caller is told it did NOT happen —
// otherwise the UI would announce an ending that never occurred.
val f = Fixture(publisher = MarmotPublisher { _, _ -> false })
f.createCurrentProfile()
val thrown = assertFailsWith<IllegalStateException> { f.manager.disbandGroup(nostrGroupId) }
assertTrue(thrown.message.orEmpty().contains("reached no relay"))
assertTrue(f.manager.groupState(nostrGroupId)?.isDisbanded != true)
assertTrue(f.manager.lifecycle(nostrGroupId) != GroupLifecycleState.DISBANDED)
// Still a working group: nothing about a failed disband may leak
// into the states that stop outbound work.
f.manager.buildTextMessage(nostrGroupId, "still here")
Unit
}
}
@@ -70,8 +70,23 @@ class MarmotScenarioRunner(
) {
private val clients = vector.clients.associateWith { VectorClient(it) }
/** Queued outbound events: (sender, event). Drained by `deliver_all`. */
private val inFlight = mutableListOf<Pair<String, Event>>()
/** Queued outbound events, drained by `deliver_all`. */
private val inFlight = mutableListOf<Queued>()
/** Messages pulled out of the queue by `withhold_message`, by label. */
private val withheld = mutableMapOf<String, MutableList<Queued>>()
/**
* One event sitting in the delivery queue, with the facts a fault selector
* matches on: who sent it, whether it is an application message or a
* commit, and — for a commit — the publication label the vector gave it.
*/
private class Queued(
val sender: String,
val event: Event,
val messageClass: String,
val publication: String?,
)
/**
* The group label the current step runs against.
@@ -130,6 +145,9 @@ class MarmotScenarioRunner(
}
}
/** The publication label the NEXT publish by this client carries, if any. */
private fun peekLabel(client: String): String? = publishOutcomes[client]?.firstOrNull()?.first?.takeIf { it.isNotEmpty() }
private fun nextOutcome(client: String): Boolean {
val queue = publishOutcomes[client] ?: return true
if (queue.isEmpty()) return true
@@ -150,8 +168,9 @@ class MarmotScenarioRunner(
SnapshotBundleStore(),
publisher =
MarmotPublisher { event, _ ->
val label = peekLabel(client.name)
val accepted = nextOutcome(client.name)
if (accepted) inFlight.add(client.name to event)
if (accepted) inFlight.add(Queued(client.name, event, COMMIT_CLASS, label))
accepted
},
)
@@ -171,6 +190,13 @@ class MarmotScenarioRunner(
"in_group" -> inGroup(step)
"clear_events" -> clearEvents(step)
"assert" -> assertPredicate(step)
"update_group_data" -> updateGroupData(step)
"remove_members" -> removeMembers(step)
"omit_message" -> omitMessage(step)
"duplicate_message" -> duplicateMessage(step)
"reorder_messages" -> reorderMessages(step)
"withhold_message" -> withholdMessage(step)
"release_withheld" -> releaseWithheld(step)
// The publication's outcome was consumed when it was made; the step
// itself carries no further state change.
"acknowledge_outbound" -> Unit
@@ -218,6 +244,11 @@ class MarmotScenarioRunner(
*/
private fun assertPredicate(step: ScenarioVector.Step) {
val assertion = step.obj("assertion") ?: error("assert without an assertion")
// `exactly` is the only mode in this set. A different one would mean a
// different comparison (at-least, at-most), so refuse rather than
// silently applying equality to it.
val mode = assertion.string("mode") ?: "exactly"
if (mode != "exactly") throw UnsupportedScenarioStep("assert/mode=$mode")
val predicate = assertion.step("predicate") ?: error("assertion without a predicate")
when (predicate.type) {
"payload_count" -> {
@@ -294,6 +325,131 @@ class MarmotScenarioRunner(
}
}
/**
* Rename the group — the vector's `update_group_data`.
*
* Only `name` ever appears in these vectors, and the current profile keeps
* it in `marmot.group.profile.v1` (`0x8001`), so this is a profile commit
* that preserves the description rather than a blanket metadata replace.
*/
private suspend fun updateGroupData(step: ScenarioVector.Step) {
val who = client(step.string("client") ?: error("update_group_data without a client"))
val groupId = who.groups[currentGroup] ?: error("${who.name} renamed a group it is not in")
val name = step.string("name").orEmpty()
val description =
who.manager
.groupView(groupId)
?.description
.orEmpty()
who.manager.setGroupProfile(groupId, name, description, emptyList())
}
/**
* Evict members by name. The vector names people; MLS removes leaves, so
* the pubkey is resolved to the leaf index the group actually holds.
*/
private suspend fun removeMembers(step: ScenarioVector.Step) {
val remover = client(step.string("remover") ?: error("remove_members without a remover"))
val groupId = remover.groups[currentGroup] ?: error("${remover.name} evicted from a group it is not in")
val targets = step.strings("members").map { client(it).signer.pubKey }.toSet()
val leaves =
remover.manager
.memberPubkeys(groupId)
.filter { it.pubkey in targets }
.map { it.leafIndex }
check(leaves.size == targets.size) {
"remove_members names ${targets.size} members but only ${leaves.size} are in the group"
}
// One Remove per commit. Every vector in this set evicts exactly one
// member per step, and the step names ONE publication — so a
// multi-member step would publish N commits against one
// acknowledgement and silently mis-align every outcome after it.
// Refuse instead, the same way an unimplemented step is refused.
check(leaves.size == 1) {
"remove_members evicts ${leaves.size} members in one step; this runner commits one " +
"Remove at a time and the vector's single acknowledgement would not line up"
}
remover.manager.removeMember(groupId, leaves.single(), emptyList())
}
/**
* Does this queued event match a fault selector?
*
* Every key is a conjunct and an unknown key is refused rather than
* ignored — a selector we silently widen would inject a different fault
* from the one the vector scripted, and still report on the vector's name.
*/
private fun matches(
queued: Queued,
selector: ScenarioVector.Step,
): Boolean {
selector.keys().forEach { key ->
when (key) {
"sender" -> if (selector.string("sender") != queued.sender) return false
"class" -> if (selector.string("class") != queued.messageClass) return false
"publication" -> if (selector.string("publication") != queued.publication) return false
// Handled by the caller: it picks which of the matches to act on.
"occurrence" -> Unit
else -> throw UnsupportedScenarioStep("selector/$key")
}
}
return true
}
/** Indices in [inFlight] the selector names, honouring `occurrence`. */
private fun select(selector: ScenarioVector.Step): List<Int> {
val all = inFlight.indices.filter { matches(inFlight[it], selector) }
val occurrence = selector.int("occurrence") ?: return all
return listOfNotNull(all.getOrNull(occurrence))
}
private fun selectorOf(step: ScenarioVector.Step) = step.obj("selector") ?: error("${step.type} without a selector")
/** Drop a queued message entirely — it never reaches anyone. */
private fun omitMessage(step: ScenarioVector.Step) {
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "omit_message matched nothing in a queue of ${inFlight.size}" }
hit.sortedDescending().forEach { inFlight.removeAt(it) }
}
/** Deliver a queued message twice. The receiver must not act on it twice. */
private fun duplicateMessage(step: ScenarioVector.Step) {
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "duplicate_message matched nothing in a queue of ${inFlight.size}" }
hit.sortedDescending().forEach { inFlight.add(it + 1, inFlight[it]) }
}
/** Deliver the queue in the order the vector names, not the order it was sent. */
private fun reorderMessages(step: ScenarioVector.Step) {
val order = step.steps("order")
val taken = mutableSetOf<Int>()
val reordered = mutableListOf<Queued>()
order.forEach { selector ->
val index = select(selector).firstOrNull { it !in taken }
checkNotNull(index) { "reorder_messages names a message that is not queued" }
taken.add(index)
reordered.add(inFlight[index])
}
inFlight.indices.filter { it !in taken }.forEach { reordered.add(inFlight[it]) }
inFlight.clear()
inFlight.addAll(reordered)
}
/** Hold a message back under a label; `release_withheld` puts it back. */
private fun withholdMessage(step: ScenarioVector.Step) {
val label = step.string("label") ?: error("withhold_message without a label")
val hit = select(selectorOf(step))
check(hit.isNotEmpty()) { "withhold_message matched nothing in a queue of ${inFlight.size}" }
val held = withheld.getOrPut(label) { mutableListOf() }
hit.sortedDescending().forEach { held.add(0, inFlight.removeAt(it)) }
}
private fun releaseWithheld(step: ScenarioVector.Step) {
val label = step.string("label") ?: error("release_withheld without a label")
val held = withheld.remove(label) ?: error("release_withheld names an unknown label '$label'")
inFlight.addAll(held)
}
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")
@@ -303,17 +459,17 @@ class MarmotScenarioRunner(
// it itself. Leaving that out meant every `send_app_message` built an
// event nobody ever delivered.
val bundle = sender.manager.buildTextMessage(groupId, payload, persistOwn = false)
inFlight.add(sender.name to bundle.outbound.signedEvent)
inFlight.add(Queued(sender.name, bundle.outbound.signedEvent, APPLICATION_CLASS, null))
}
private fun deliverAll() {
val batch = inFlight.toList()
inFlight.clear()
batch.forEach { (senderName, event) ->
batch.forEach { queued ->
// Broadcast to everyone else, including clients who are not in the
// sending group. Their engine refusing that traffic is precisely
// what multigroup isolation asserts.
clients.values.filter { it.name != senderName }.forEach { it.inbox.add(event) }
clients.values.filter { it.name != queued.sender }.forEach { it.inbox.add(queued.event) }
}
}
@@ -348,14 +504,23 @@ class MarmotScenarioRunner(
}
}
/**
* The membership this client currently sees, or empty when it can no
* longer see the group at all — a client that was just evicted has no
* roster to read, and the vectors reach that state on purpose.
*/
private fun membersOf(
client: VectorClient,
groupId: HexKey,
): Set<HexKey> =
client.manager
.memberPubkeys(groupId)
.map { it.pubkey }
.toSet()
try {
client.manager
.memberPubkeys(groupId)
.map { it.pubkey }
.toSet()
} catch (_: Exception) {
emptySet()
}
/** Compare every client's end state against the vector's expected trace. */
private fun verify() {
@@ -404,6 +569,10 @@ class MarmotScenarioRunner(
val got = client.manager.groupView(groupId)?.name
if (got != want) failures.add("${expected.client}[$label] group name '$got', expected '$want'")
}
expected.groupDescription?.let { want ->
val got = client.manager.groupView(groupId)?.description
if (got != want) failures.add("${expected.client}[$label] group description '$got', expected '$want'")
}
}
if (expected.receivedPayloads.isNotEmpty()) {
val got = client.received.sorted()
@@ -427,6 +596,10 @@ class MarmotScenarioRunner(
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"
/** The label for a vector that never says `in_group` — most of them. */
const val DEFAULT_GROUP = "default"
}
@@ -86,6 +86,37 @@ class MarmotScenarioVectorTest {
@Test
fun conversation() = replay("conversation.v1.json")
/** A rename lands on every member as a commit, not as a hint. */
@Test
fun groupDataUpdate() = replay("group-data-update.v1.json")
/** A client that missed several rounds catches up on one tick. */
@Test
fun deferredTickCatchup() = replay("deferred-tick-catchup.v1.json")
/** Members added at each step see only what came after them. */
@Test
fun incrementalGrowth() = replay("incremental-growth.v1.json")
/**
* The delivery-fault family: a dropped message, and a queue that duplicates
* and reorders before it delivers. What arrives twice must be acted on
* once, and out-of-order arrival must not change the end state.
*/
@Test
fun dropQueued() = replay("drop-queued.v1.json")
@Test
fun queueFaults() = replay("queue-faults.v1.json")
/** An application message from an epoch the group has already left. */
@Test
fun delayedPastEpochAppMessage() = replay("delayed-past-epoch-app-message.v1.json")
/** Evicted and invited back: the second membership is not the first. */
@Test
fun readdAfterEviction() = replay("readd-after-eviction.v1.json")
/**
* `convergence-committer-selected` concludes with a `convergence_decision`
* — which tip the client picked, under which rule, and whether the witness
@@ -151,6 +182,13 @@ class MarmotScenarioVectorTest {
"three-client-message-exchange.v1.json",
"conversation.v1.json",
"convergence-committer-selected.v1.json",
"group-data-update.v1.json",
"deferred-tick-catchup.v1.json",
"incremental-growth.v1.json",
"drop-queued.v1.json",
"queue-faults.v1.json",
"delayed-past-epoch-app-message.v1.json",
"readd-after-eviction.v1.json",
)
}
}
@@ -76,6 +76,19 @@ class ScenarioVector(
/** A nested object read as a [Step] so the same accessors work on it. */
fun obj(key: String): Step? = (raw[key] as? JsonObject)?.let { Step(key, it) }
/** A nested array of objects, each read as a [Step]. */
fun steps(key: String): List<Step> =
(raw[key] as? JsonArray)
?.mapNotNull { element ->
(element as? JsonObject)?.let { Step((it["type"] as? JsonPrimitive)?.content.orEmpty(), it) }
}.orEmpty()
/**
* The keys this object carries. A fault selector is checked key by key
* so an unknown one can be refused rather than quietly widening it.
*/
fun keys(): Set<String> = raw.keys
}
/** What one client's state must look like when the script says to look. */
@@ -91,6 +104,8 @@ class ScenarioVector(
* list, and an empty list is itself an assertion.
*/
val addedMembers: List<String>? = null,
/** The group description the vector states, when it states one. */
val groupDescription: String? = null,
)
/**
@@ -160,6 +175,21 @@ class ScenarioVector(
)
}
// A profile assertion is per-client state like any other,
// just stated separately because it is about the group's
// metadata rather than its membership.
"group_profile" ->
stated.add(
Observation(
client = obj.getValue("client").jsonPrimitive.content,
epoch = null,
memberCount = null,
groupName = (obj["name"] as? JsonPrimitive)?.takeIf { it.isString }?.content,
receivedPayloads = emptyList(),
groupDescription = (obj["description"] as? JsonPrimitive)?.takeIf { it.isString }?.content,
),
)
"pending_resolution" ->
resolutions.add(
PendingResolution(
@@ -11,14 +11,37 @@ fixtures we already consume from `quartz/src/commonTest/resources/marmot/
conformance/`. The rest are **scenario scripts**: a client roster, a step
list, and an expected trace.
These nine are the subset whose steps `MarmotScenarioRunner` implements —
`create_group`, `invite_members`, `send_app_message`, `deliver_all`, `tick`,
`acknowledge_outbound`, `observe`, `in_group`, `assert`, `clear_events`. The
other nineteen need fault injection (withhold/release, partition, duplicate,
reorder, restart) or group-data and admin-policy steps; the runner refuses
These sixteen are the subset whose steps `MarmotScenarioRunner` implements:
`create_group`, `invite_members`, `remove_members`, `send_app_message`,
`update_group_data`, `deliver_all`, `tick`, `acknowledge_outbound`, `observe`,
`in_group`, `assert`, `clear_events`, and the queue faults `omit_message`,
`duplicate_message`, `reorder_messages`, `withhold_message`,
`release_withheld`. The rest need `restart_client`, `set_partition`, `leave`,
admin-policy steps, or the `convergence_decision` outcome; the runner refuses
them by name rather than skipping quietly, so adding a step type is what
widens the set.
## Two expectation shapes
A vector states what must be true in one of two ways, and BOTH have to be
read:
- `expected_trace.observations` — the older shape (`publish-fail`,
`three-client-message-exchange`).
- `expected_outcomes` — a list of typed entries: `client_state`,
`clients_converged`, `group_profile`, `pending_resolution`,
`no_pending_work`, and `convergence_decision`. Everything else here uses
this one.
Reading only the first is not a partial check, it is no check: a vector whose
expectations all live in the other shape replays its steps and reports green
having compared nothing. `MarmotScenarioVectorTest.everyVectorStatesSomethingToCheck`
exists to make that failure loud rather than invisible.
An outcome type the runner cannot evaluate raises `UnsupportedScenarioOutcome`
and the vector is refused — `convergence-committer-selected` is refused today
for exactly that reason.
## Refreshing
```bash
@@ -0,0 +1,272 @@
{
"scenario_name": "deferred-tick-catchup/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "deferred-tick-catchup/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "reconnect",
"invitees": [
"bob",
"carol",
"dave"
],
"required_features": [],
"pending": "create",
"initial_admins": [
"alice"
]
},
{
"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": "reconnect:warm:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:warm:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:warm:carol"
},
{
"type": "send_app_message",
"sender": "dave",
"payload": "reconnect:warm:dave"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "reconnect:away:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:away:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:away:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "reconnect-renamed",
"pending": "rename"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "reconnect:back:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "reconnect:back:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "reconnect:back:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "tick",
"clients": [
"dave"
]
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 17,
"client": "alice",
"pending": "rename",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "dave",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"reconnect:warm:alice",
"reconnect:warm:bob",
"reconnect:warm:carol",
"reconnect:away:alice",
"reconnect:away:bob",
"reconnect:away:carol",
"reconnect:back:alice",
"reconnect:back:bob",
"reconnect:back:carol"
]
},
{
"type": "client_state",
"client": "alice",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"reconnect:warm:bob",
"reconnect:warm:carol",
"reconnect:warm:dave",
"reconnect:away:bob",
"reconnect:away:carol",
"reconnect:back:bob",
"reconnect:back:carol"
]
},
{
"type": "group_profile",
"client": "alice",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "bob",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "carol",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "group_profile",
"client": "dave",
"name": "reconnect-renamed",
"description": ""
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"epoch": 2,
"member_count": 4
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
}
@@ -0,0 +1,132 @@
{
"scenario_name": "delayed-past-epoch-app-message/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "delayed-past-epoch-app-message/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"david"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "delayed-past-epoch-app",
"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",
"david"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "epoch-one-delayed"
},
{
"type": "withhold_message",
"selector": { "sender": "bob", "class": "application" },
"label": "old-app"
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"david"
],
"pending": "invite-david"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-david",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"carol",
"david"
]
},
{
"type": "release_withheld",
"label": "old-app"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"carol"
]
},
{
"type": "observe",
"clients": [
"carol"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 8,
"client": "alice",
"pending": "invite-david",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "carol",
"epoch": 2,
"member_count": 4,
"received_payloads": [
"epoch-one-delayed"
]
}
]
}
@@ -0,0 +1,95 @@
{
"scenario_name": "drop-queued/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "drop-queued/v1",
"spec_version": "2",
"clients": [
"alice",
"bob"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "drop",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob"
]
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:dropped"
},
{
"type": "omit_message",
"selector": { "sender": "bob", "class": "application" }
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "bob:delivered"
},
{
"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": 2,
"received_payloads": [
"bob:delivered"
]
}
]
}
@@ -0,0 +1,127 @@
{
"scenario_name": "group-data-update/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "group-data-update/v1",
"spec_version": "2",
"clients": [
"alice",
"bob"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "before",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "after",
"pending": "rename"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "observe",
"clients": [
"alice",
"bob"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 6,
"client": "alice",
"pending": "rename",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 2,
"member_count": 2,
"received_payloads": []
},
{
"type": "client_state",
"client": "bob",
"epoch": 2,
"member_count": 2,
"received_payloads": []
},
{
"type": "group_profile",
"client": "alice",
"name": "after",
"description": ""
},
{
"type": "group_profile",
"client": "bob",
"name": "after",
"description": ""
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob"
],
"epoch": 2,
"member_count": 2
}
]
}
@@ -0,0 +1,376 @@
{
"scenario_name": "incremental-growth/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "incremental-growth/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "growth-2",
"invitees": [
"bob"
],
"required_features": [],
"pending": "create"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "create",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob"
]
},
{
"type": "clear_events",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p0:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p0:bob"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob"
]
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"carol"
],
"pending": "invite-carol"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-carol",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "growth-3",
"pending": "rename-3"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename-3",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p1:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p1:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "growth:p1:carol"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol"
]
},
{
"type": "invite_members",
"inviter": "alice",
"invitees": [
"dave"
],
"pending": "invite-dave"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "invite-dave",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "update_group_data",
"client": "alice",
"name": "growth-4",
"pending": "rename-4"
},
{
"type": "acknowledge_outbound",
"client": "alice",
"publication": "rename-4",
"outcome": "accepted"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"bob",
"carol",
"dave"
]
},
{
"type": "send_app_message",
"sender": "alice",
"payload": "growth:p2:alice"
},
{
"type": "send_app_message",
"sender": "bob",
"payload": "growth:p2:bob"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "growth:p2:carol"
},
{
"type": "send_app_message",
"sender": "dave",
"payload": "growth:p2:dave"
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "carol",
"payload": "growth:p0:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "growth:p0:alice",
"count": 0
}
}
},
{
"type": "assert",
"assertion": {
"mode": "exactly",
"predicate": {
"type": "payload_count",
"client": "dave",
"payload": "growth:p1:alice",
"count": 0
}
}
},
{
"type": "observe_exact",
"clients": [
"alice",
"bob",
"carol",
"dave"
]
}
]
},
"expected_outcomes": [
{
"type": "pending_resolution",
"step_index": 1,
"client": "alice",
"pending": "create",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 10,
"client": "alice",
"pending": "invite-carol",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 14,
"client": "alice",
"pending": "rename-3",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 23,
"client": "alice",
"pending": "invite-dave",
"resolution": "confirmed"
},
{
"type": "pending_resolution",
"step_index": 27,
"client": "alice",
"pending": "rename-4",
"resolution": "confirmed"
},
{
"type": "client_state",
"client": "alice",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p0:bob",
"growth:p1:bob",
"growth:p1:carol",
"growth:p2:bob",
"growth:p2:carol",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "bob",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p0:alice",
"growth:p1:alice",
"growth:p1:carol",
"growth:p2:alice",
"growth:p2:carol",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "carol",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p1:alice",
"growth:p1:bob",
"growth:p2:alice",
"growth:p2:bob",
"growth:p2:dave"
]
},
{
"type": "client_state",
"client": "dave",
"epoch": 5,
"member_count": 4,
"received_payloads": [
"growth:p2:alice",
"growth:p2:bob",
"growth:p2:carol"
]
},
{
"type": "clients_converged",
"clients": [
"alice",
"bob",
"carol",
"dave"
],
"epoch": 5,
"member_count": 4
},
{
"type": "no_pending_work",
"clients": [
"alice",
"bob"
]
}
]
}
@@ -0,0 +1,125 @@
{
"scenario_name": "queue-faults/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "queue-faults/v1",
"spec_version": "2",
"clients": [
"alice",
"bob",
"carol"
],
"steps": [
{
"type": "create_group",
"creator": "alice",
"name": "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:first"
},
{
"type": "send_app_message",
"sender": "carol",
"payload": "carol:second"
},
{
"type": "duplicate_message",
"selector": { "sender": "bob", "class": "application" }
},
{
"type": "withhold_message",
"selector": { "sender": "bob", "class": "application", "occurrence": 1 },
"label": "delayed-copy"
},
{
"type": "reorder_messages",
"order": [
{ "sender": "carol", "class": "application" },
{ "sender": "bob", "class": "application" }
]
},
{
"type": "deliver_all"
},
{
"type": "tick",
"clients": [
"alice"
]
},
{
"type": "release_withheld",
"label": "delayed-copy"
},
{
"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": [
"carol:second",
"bob:first"
]
}
]
}
@@ -0,0 +1,37 @@
{
"scenario_name": "readd-after-eviction/v1",
"vector_version": "1",
"conformance_version": "0.9.19",
"seed": null,
"scenario": {
"name": "readd-after-eviction/v1",
"spec_version": "2",
"clients": ["alice", "bob", "carol"],
"steps": [
{"type": "create_group", "creator": "alice", "name": "readd", "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": "remove_members", "remover": "alice", "members": ["carol"], "pending": "remove-carol"},
{"type": "acknowledge_outbound", "client": "alice", "publication": "remove-carol", "outcome": "accepted"},
{"type": "deliver_all"},
{"type": "tick", "clients": ["bob", "carol"]},
{"type": "invite_members", "inviter": "alice", "invitees": ["carol"], "pending": "readd-carol"},
{"type": "acknowledge_outbound", "client": "alice", "publication": "readd-carol", "outcome": "accepted"},
{"type": "deliver_all"},
{"type": "tick", "clients": ["bob", "carol"]},
{"type": "send_app_message", "sender": "carol", "payload": "carol:back"},
{"type": "deliver_all"},
{"type": "tick", "clients": ["alice", "bob"]},
{"type": "observe", "clients": ["alice", "bob", "carol"]}
]
},
"expected_outcomes": [
{"type": "pending_resolution", "step_index": 6, "client": "alice", "pending": "remove-carol", "resolution": "confirmed"},
{"type": "pending_resolution", "step_index": 10, "client": "alice", "pending": "readd-carol", "resolution": "confirmed"},
{"type": "client_state", "client": "alice", "epoch": 3, "member_count": 3, "received_payloads": ["carol:back"]},
{"type": "client_state", "client": "bob", "epoch": 3, "member_count": 3, "received_payloads": ["carol:back"]},
{"type": "client_state", "client": "carol", "epoch": 3, "member_count": 3, "received_payloads": []}
]
}