fix(marmot): leaveGroup sends a SelfRemove-only commit, not a standalone proposal

Quartz's leaveGroup was returning `proposal.toTlsBytes()` and
publishing that raw on kind:445. That's not a valid MLS message
— it's just the proposal struct with no FramedContent / MlsMessage
framing — and even if it parsed, mdk/openmls would only STAGE the
proposal. The target never actually leaves the tree until someone
commits with that proposal.

MIP-03 explicitly allows non-admins to commit a SelfRemove-only
commit, so amy can produce a proper committable kind:445 herself
after the self-demote. Replace `selfRemove()` (returned raw bytes)
with `selfRemoveCommit()` which adds the proposal to the pending
set and runs the standard `commit()` path. Plumb the resulting
CommitResult through MarmotManager.leaveGroup so the outer
encryption uses `preCommitExporterSecret` (the epoch we're
leaving, which is the one every existing member still has).

This finally propagates amy's leave to B in test 11.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
This commit is contained in:
Claude
2026-04-22 07:46:05 +00:00
parent d0fb8ac788
commit b01ee5336a
3 changed files with 37 additions and 17 deletions
@@ -290,15 +290,18 @@ class MarmotManager(
* Returns proposal bytes to publish (as a GroupEvent).
*/
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
// Build the outbound event BEFORE deleting group state (needs exporter secret)
val group =
groupManager.getGroup(nostrGroupId)
?: throw IllegalStateException("Not a member of group $nostrGroupId")
val proposalBytes = group.selfRemove()
val outboundEvent = outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes)
// leaveGroup() builds a SelfRemove-only commit (MIP-03 non-admin
// exception) and removes local state. It must run BEFORE we tear
// down the subscriptions so the pre-commit exporter key is still
// reachable for outer encryption.
val commitResult = groupManager.leaveGroup(nostrGroupId)
val outboundEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = commitResult.framedCommitBytes,
exporterKey = commitResult.preCommitExporterSecret,
)
// Now clean up group state
groupManager.removeGroupState(nostrGroupId)
subscriptionManager.unsubscribeGroup(nostrGroupId)
try {
messageStore?.delete(nostrGroupId)