Files
buzz/crates
Will Pfleger ce47aefdc3 fix(relay): send membership notices on channel delete, moderation kick and moderation DM (#8977)
Related: [buzz-app#2215](https://github.com/block/buzz-app/pull/2215)

## Problem

A connected client learns about membership changes from kind:44100/44101
notices and then re-reads its channel list. Three relay paths changed a
member's channel list without sending a notice, so clients only caught
up on their periodic full read:

- **Channel delete (9008):** no member was told.
- **Moderation kick:** the kicked member wasn't told, and the channel's
39000/39002 discovery wasn't refreshed, so it still listed them.
- **Moderation DM:** a new, existing or hidden moderation DM didn't
surface live.

## Change

- **Channel delete:** `soft_delete_channel_capturing_members` (buzz-db)
reads the channel type and active members under deletion's existing
membership lock, in the same transaction. A join either commits first
and is included, or waits and is then refused. After the commit,
discovery cleanup and cache invalidation, the relay sends a type-tagged
44101 to each captured member in a spawned task, eight at a time,
outside any lock. The 9008 OK doesn't wait for them, and the task is
started before the `channel_deleted` system message is emitted, so a
system-message failure can't drop them. An already-deleted channel sends
nothing. `soft_delete_channel` keeps its signature and delegates.
- **Moderation kick:** `apply_kick_live_side_effects` refreshes
39000/39002 after the removal fence commits, then sends 44101 to the
kicked member. If the refresh fails, it returns the error before the
notice, so the moderation driver's existing mutation-marker recovery
retries. If the channel was deleted since the kick, there's no roster to
refresh: it removes the channel's discovery events and treats that as
converged, so the action still finalizes. The removal/re-add fence and
the re-added early return are unchanged.
- **Moderation DM:** `send_moderation_notice` drops both participants'
cached channel sets on every pod (`invalidate_membership`), as opening a
DM does in `command_executor::handle_dm_open`, so the read the notice
prompts includes the DM. After clearing `hidden_at`, it republishes the
recipient's NIP-DV kind:30622 visibility snapshot (clients filter the
sidebar on it), then emits discovery, then sends 44100. It sends the
notice on every delivery, tagged `moderation_source` with the delivery's
source id, so two deliveries in the same second each get their own
44100. The content JSON is unchanged. A failed snapshot or discovery
returns an error, so the outbox retries.
- **Ordering guarantee:** each notice is sent after its change is
committed and readable on the relay instance that sends it. Ordering
across relay instances, and other instances' cached channel sets, are
best-effort, the same as for existing membership notices.
- A failed notice delivery is logged and never undoes the change, like
the existing 9001 path. There's no new retry system.

## Tests

- `membership_notice_postgres_tests.rs`: one journey per path. A
`#[cfg(test)]` barrier at the point a notice is signed parks the
producer, keyed by recipient. While it's parked, the test reads the
channel list (and, for the DM, the 30622 snapshot) as a client would on
receipt. The barrier can also pin a notice's `created_at`.
- The DM journey pins both deliveries to the same second, hides the DM
through the real kind:41012 path between them, and checks what a client
sees: a live 44100 for each delivery, with distinct ids, then a
channel-list read that lists the DM and a visibility read that no longer
hides it.
- `moderation_dm_notice_follows_a_cached_channel_list` warms the
recipient's channel set with a read before the DM exists, then checks
the read on receipt of the notice lists the DM.
- `soft_delete_captures_members_at_its_serialization_point` (buzz-db): a
join and then a delete queue behind a held membership lock. The delete
must return the joiner, and a later join is refused.
- `crash_recovery_finalizes_kick_after_channel_deleted` (admin): commits
a kick, deletes the channel, then runs the recovery worker. The action
finalizes, and the member's `removed_at` is unchanged, so the removal
doesn't run again.

Each ordering test fails when its prerequisite is moved or removed: the
kick notice ahead of the roster refresh, the delete notices ahead of
discovery cleanup, the DM without the snapshot republish or the cache
drop, the DM notice without its source tag (the second notice
coalesces), and the member capture outside the lock. The recovery test
fails if a deleted channel's `ChannelNotFound` propagates.

No spec files change. This reads a channel delete as removing every
member under the existing `NOSTR.md` notice promise.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-03 13:57:34 -05:00
..