feat(relay): tag every membership notice with its channel type (#8406)

🤖
## Summary
- When someone is added to or removed from a system channel, the relay
now sends them the usual "you were added / removed" notice. Before, it
sent nothing, so a client could learn about a new system channel only by
rereading every channel.
- **A client sees a notice only if it knows the channel's type.** Every
membership notice now carries its channel type. Clients that do not send
`#t` (every client today) see only the types that existed before system
channels, so they see no change. A type that Buzz adds later is hidden
from them by default.
- The notices are stored like every other membership notice, so a client
that was offline can catch up with `since` instead of rereading all its
channels.

## Details
- Every kind:44100 (added) and kind:44101 (removed) notice now carries
`["t", <channel_type>]`, for example `["t","stream"]` or
`["t","system"]`.
- **One place reads the channel type:** `emit_membership_notification`
in `side_effects.rs`, where the relay signs the notice. A channel's type
is set when it is created and no code path changes it, so the tag stays
correct. If the channel row is missing, the relay sends no notice rather
than an untagged one.
- **The rule (NIP-CL):**
- a filter with `#t` matches a notice only when the notice's type is one
of its `#t` values;
- a filter without `#t` matches only the **legacy types**: `stream`,
`forum`, `dm`, `workflow`. This list is frozen
(`LEGACY_NOTICE_CHANNEL_TYPES`), so a new type never joins it;
- a notice without a `t` tag was stored before this change. It is always
a legacy type, so the relay treats it as one, and replay across the
upgrade stays complete.
- **Fails closed.** The rule lives in two shared places, and both
default to the legacy types. A read path that is not updated behaves as
it does today:
- `filters_match` / `notice_visible` (buzz-core). Live fan-out, the REQ
history post-filter, COUNT, the HTTP bridge and push all match through
it.
- `EventQuery::notice_channel_types` (buzz-db). Its SQL keeps only
visible notices before `LIMIT`, so page limits and COUNT stay exact.
`None` means legacy types. Only `filter_to_query_params` sets it, from
the filter's `#t`.
- A client that knows every current type sends one filter:
`{"kinds":[44100,44101],"#p":[<me>],"#t":["stream","forum","dm","workflow","system"]}`.
NIP-CL and NOSTR.md describe this.
- **Only the target receives a notice.** A notice names the target, the
channel and who made the change, and the relay signs it, so knowing its
id is not permission to read it. 44100/44101 are now in
`RESULT_GATED_KINDS`, the one list used by the `ids` exemption check,
the per-result reader check (WS and HTTP history, push), live fan-out
(same pod and Redis) and COUNT. This also closes the same id lookup for
ordinary channels' notices.
- No migration: 44100/44101 are already excluded from search indexing.

## Testing
- `buzz-core`: a table test covers untagged, `stream`, `system` and a
future type against filters without `#t`, with `#t` for one type, and
with `#t` for every type. Other kinds keep the plain `#t` match.
- Postgres tests (`system_channel_postgres_tests.rs`): live fan-out, REQ
history, COUNT and the HTTP bridge, for a filter without `#t`, a
`#t:["system"]` filter and an all-types filter; an untagged legacy
notice; a non-target with an `ids` filter on each path; and a raw
`EventQuery` that does not set the field.
- I broke each part on purpose and the tests failed: no type tag on the
notice, no rule in `filters_match`, no SQL rule, and no untagged clause
in the SQL.
- End to end: I built a relay container from f24f8e7ba and ran
buzz-app's `container-relay.e2e.test.ts` (block/buzz-app#1290) against
it. The live system channel test passed 5 of 5 runs.

Paired client change: block/buzz-app#1290. It works unchanged against
this relay.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-19 16:29:32 -04:00
committed by GitHub
parent 91e7b469b8
commit cc6336d985
11 changed files with 736 additions and 79 deletions

View File

@@ -186,9 +186,52 @@ a system channel stays an ordinary NIP-29 group.
The relay decides this for each filter. A filter that names a system channel
does not add it to another filter in the same request.
The relay MUST NOT send push notifications or kind:44100/44101 membership
notifications for system channels. A client that received one would show the
channel.
The relay MUST NOT send push notifications for system channels.
### Membership notices carry the channel type
Every kind:44100 (added) and kind:44101 (removed) notice has one more tag,
`["t", <channel_type>]`, for example `["t", "stream"]` or `["t", "system"]`.
The relay sets it from the channel type when it sends the notice. A channel's
type does not change. The relay stores the notice like any other 44100/44101.
A client sees a notice only if it knows the notice's channel type. Old
clients do not know types added later, such as `system`, and would show the
channel from such a notice. So the relay MUST apply this rule:
- **A filter with `#t`** matches a notice only when the notice's type is one
of the `#t` values.
- **A filter without `#t`** matches a notice only when its type is one of the
**legacy types**: `stream`, `forum`, `dm`, `workflow`. This list is fixed.
A type added later is never on it, so old clients never see its notices.
- **A notice without a `t` tag** was stored before this rule. Every such
notice is about a legacy type, so the relay treats it as all of them: it
matches a filter without `#t`, and a filter whose `#t` has a legacy type.
On these two kinds, this replaces the plain NIP-01 `#t` match.
The rule applies on every path: REQ history, live delivery, COUNT, and any
relay-specific query API. Existing clients do not send `#t` on these kinds,
so they see no change.
A client that knows every current type sends one filter:
```json
{"kinds": [44100, 44101], "#p": ["<me>"], "#t": ["stream", "forum", "dm", "workflow", "system"]}
```
When Buzz adds a type, a client adds its value to `#t` when it supports that
type.
The `#p` rule for 44100/44101 still applies: it must be the reader's own
pubkey. An `ids` filter does not lift it: the relay returns, counts or
delivers a 44100/44101 only to the pubkey in its `p` tag, even to a reader
who knows the event id.
Because the notices are stored, a client that reconnects can set `since` to
the newest notice it has seen. One subscription then returns the notices it
missed and the new ones, so no change falls between a read and a
subscription. A notice is a hint: the channel's group-state events decide
membership.
### Asking for system channels