Files
buzz/docs/nips/NIP-CL.md
Matt Toohey cde6c1bf5b feat(relay): auto-join new community members to default channels (#8594)
## Summary

Adds **default channels**: relay owners and admins can flag channels
that every new community member is automatically joined into.

**Setting (contract)**
- New `channels.auto_join` column (migration `0080`, constant default
`false`, so no table rewrite).
- kind:9002 accepts `["auto_join", "true"|"false"]`. Authorization comes
from the actor's **relay** roster role (owner/admin), not a channel
role, so the actor doesn't need to be a channel member. API tokens need
`AdminChannels`.
- Only open, unarchived `stream` channels qualify. This is checked under
the channel row lock, so one event can open and flag a channel in one
step, and a concurrent archive can't slip past the check.
- kind:39000 carries a bare `auto_join` tag only while the setting is
effective (on, open and unarchived). Every change posts one
`auto_join_changed` kind:40099 notice.
- SDK `build_update_channel(.., auto_join)`, `buzz channels update
--auto-join <true|false>`, a NOSTR.md "Default channels" section, and a
NIP-CL tag-table row.

**Behaviour**
- kind:9021's body is pulled out into `join_channel_as_self`, so
auto-join uses the same path as a self-join: the channel's `join_role`
applies, and it emits `member_joined` 40099, 39000/39002 and 44100.
- `auto_join_default_channels` runs inline right after a member is added
at three sites: the v2 invite claim, the v1 invite claim, and kind:9030
add. It does not run on login or reconnect, so a later kind:9022 leave
stands.
- Best effort: each failure is logged and counted
(`buzz_auto_join_failures_total`) but never retried, because the roster
membership has already committed. This deliberately departs from
Review-Proven Rule 1 and is documented in NOSTR.md. `buzz-admin
add-member` writes the roster row only.

**Communities without a membership roster**
(`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`)
- These have no invite claim or kind:9030 to hook, so the three sites
above join nobody there. Instead, after the AUTH `OK`, the relay joins a
person to every effective default channel they have **never had a
membership row in** (`auto_join_never_joined_channels`, spawned so
admission never waits on it).
- The channel's own membership history is the once-only record, so this
adds no new table, column or migration. A kind:9022 leave or a kind:9001
removal keeps the row with `removed_at`, so the person stays out. A new
device or another relay pod finds the row and does nothing.
`put_first_member` checks the row again under the `put_member` locks, so
two admissions racing each other join once.
- A channel flagged later is joined on each person's next admission. A
failed membership write leaves no row, so the next admission retries it.
- Each channel's join holds the session's NIP-FI effect permit, so once
the socket closes or its admission expires, no further channels are
joined.
- Agents are skipped: any socket with an admitted owner (a NIP-OA tag, a
NIP-FI agent token, or a stored `agent_owner_pubkey`).
- The kind:9021 side effects moved into `announce_self_join`, so both
join paths emit the same `member_joined`, 39000/39002 and 44100.

**Review follow-ups**
- `get_accessible_channels` now selects `auto_join`. Without it, every
call failed with `ColumnNotFound`.
- The final authorization branch of the 9002 validator now refuses by
default. `list_auto_join_channels` filters to `stream` channels itself.

## Testing
- Postgres-lane journeys:
- `edit_metadata_postgres_tests`: who may set `auto_join`, the 40099
notice, the effective-state tag, and the refusals.
- `claim_auto_joins_default_channels_once`: v2 and v1 claims, and leave
then re-claim.
  - `adding_a_member_auto_joins_default_channels`: kind:9030 add.
- Removing the auto-join call at any one of the three sites fails the
matching test (checked by mutation).
- `root_auth_joins_people_to_default_channels_without_a_roster`: through
`handle_auth` on a roster-less community. A person is joined, a NIP-OA
agent is not, and a leave holds when the person is admitted again.
Mutations checked: removing the spawn, and dropping the agent check.
Each check waits for that admission's own auto-join outcome (a skip
reports through the same test hook as a finished task) and fails if none
arrives. A third mutation drops the agent check and delays the task 3s;
it still fails.
- `admission_auto_join_stops_when_the_session_ends`: closing the socket
before the first or second channel stops the remaining joins; a later
admission joins what was skipped. Mutation checked: dropping the effect
permit.
- `first_member_put_joins_once_and_never_undoes_a_leave` (buzz-db): the
person is joined once with the channel's `join_role` (`guest` on an
announce channel), a second put does nothing, after a leave the channel
is neither listed nor joined, and two racing puts write exactly once.
Mutations checked: removing the row check, removing the channel lock,
and removing the `NOT EXISTS`.
- e2e `test_invite_claim_auto_joins_default_channel` against a local
relay.
- Unit coverage in the SDK and CLI builders and `group_metadata_tags`.

## What got simpler

The roster-less path adds no schema and no new concept: it reuses the
channel's membership history as its record. It also shares the kind:9021
join code. It replaces the per-device client workarounds,
[buzz-app#2029](https://github.com/block/buzz-app/pull/2029) and
[buzz#8795](https://github.com/block/buzz/pull/8795), which are now
closed.

## Related

- Client half (buzz-app): https://github.com/block/buzz-app/pull/1624

## Demo

Recorded against a local relay built from this branch with throwaway
identities (Alice = owner, Bob = new member); no real accounts or hosted
relays involved. Relay at 141aa01d4, client at buzz-app bb0d97d86
(browser dev build).

**Owner turns on "Starter channel for new members" on two channels.**
Alice creates `#general` and `#welcome`, then flips the switch on each
(~0:25 and ~0:41). The switch greys out while the kind 9002 is in flight
and only turns on once the republished 39000 carries the `auto_join`
tag.


https://github.com/user-attachments/assets/e590d343-11d3-4385-af43-2cfaf19f8468

**A brand-new member joins and lands in a starter channel.** Bob claims
Alice's invite with empty storage; from ~0:18 he is reading `#welcome`
with both `#general` and `#welcome` in the sidebar, and at 0:27 opens
`#general` as a member with no Join prompt. The relay logged one
`auto-joined default channel` line per flagged channel.


https://github.com/user-attachments/assets/037a0aaa-b25d-474d-a316-8d25e5e54f89

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-11-01 14:17:41 -05:00

13 KiB
Raw Blame History

NIP-CL

Channel Identity and Discovery

draft optional relay

This NIP adds identity facts to the relay-signed NIP-29 channel-state events (kinds 39000–39002). With these facts, a client can find channels with ordinary NIP-01 filters. It needs no new filter key and no relay-specific API.

Motivation

Plugins and agents often create a channel for their own use and must find it again later, for example "the config channel that this agent created". With plain NIP-29, a client cannot:

  • tell who created a channel;
  • read the channel type from any event except kind:39000; or
  • tell which of two channels is older. The created_at of a kind:39000 event is the time of the last edit, not the time of creation.

A client also cannot mark its own channels so that it can find them again. Channel labels (below) do this.

Also, every channel that a person belongs to shows in their client as a chat. So a group that exists only to control who can read some data appears in sidebars, search results and unread counts. System channels (below) fix this.

Channel identity tags

On every channel-state event that the relay signs, the relay MUST put these tags in this order, before any other t or P tag:

  1. ["t", <channel type>], for example stream, forum, dm, workflow, system.
  2. ["t", <label>] for each channel label, in stored order (see Channel labels).
  3. ["P", <creator public key, hex>].

kind:39000 also carries ["created_at", <unix seconds>].

Example kind:39000 (signing fields and unrelated tags omitted):

{
  "kind": 39000,
  "pubkey": "<relay public key>",
  "created_at": 1760025600,
  "tags": [
    ["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"],
    ["name", "agent-config"],
    ["t", "stream"],
    ["t", "agent-config"],
    ["P", "<creator public key>"],
    ["created_at", "1759939200"]
  ]
}
Tag Kinds Meaning
first t 39000–39002 Channel type.
later t 39000–39002 Channel labels.
P 39000–39002 Public key that signed the kind:9007 that created the channel.
created_at 39000 Time the channel was created, in unix seconds, as a decimal string.
auto_join 39000 Present (bare, no value) while the relay joins new community members into this channel: the channel's auto_join setting is on and it is open and unarchived.

Channel type. Clients MUST read the channel type from the first t tag. Later t tags are labels.

Creator. The relay writes P from its own record of the kind:9007 that created the channel. No client can set it. It does not change when ownership moves to another member or when the channel is edited.

Uppercase P follows NIP-22, NIP-34 and NIP-72, where P names the author of the root object. On these kinds, lowercase p already lists members, admins and DM participants. A filter matches tag values, not their roles, so #p would mix creators with members.

Creation time. The created_at tag does not change when the channel is edited. Use it, not the event's created_at, to compare the age of channels.

Older events. A relay adds these tags only when it publishes a channel-state event. An event that a relay version without this NIP published can lack them, including one published during an upgrade. Such an event stays until the channel next changes, for example on an edit or a membership change. A client MUST treat a missing tag as unknown. A missing P does not mean that a key did not create the channel.

Channel labels

A label groups channels across channel types, for example workspace or agent-config. Labels have no meaning to the relay.

Set labels. A kind:9007 (create group) MAY carry up to 8 distinct labels as ["t", <label>] tags. On kind:9002 (edit metadata):

  • t tags replace the whole label set;
  • ["t", ""] tags and no other t tag clear the set;
  • no t tag leaves the labels as they are.

Only an owner or admin of the channel MAY change its labels.

Rules. A label is 1–64 characters from a-z, 0-9, ., : and -. A label MUST NOT be stream, forum, dm, workflow or system. These are channel type names, so this rule stops a label from making a channel look like another type. The relay MUST reject the whole event if it has more than 8 distinct labels, an invalid label, a reserved name, or ["t", ""] together with other labels. The relay stores repeated labels once, so a repeat does not count toward the 8, and repeated ["t", ""] tags clear the set like one.

Finding channels

Use ordinary NIP-01 filters on the channel-state kinds.

To find Filter
Channels that a key created {"kinds": [39000], "#P": ["<creator>"]}
Channels with a label {"kinds": [39000], "#t": ["<label>"]}
One creator's channels with a label {"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]}
Channels of one type {"kinds": [39000], "#t": ["<type>"]}

A #P filter does not return a channel whose events lack P (see Older events). So it can miss channels that a key created before the relay supported this NIP.

Values in one tag filter combine with OR, as in NIP-01. So "#t": ["workspace", "notes"] finds channels with either label. To require a label and a creator, use #t and #P together.

The relay MUST match #t and #P before it applies limit, as NIP-01 requires. A relay that reads the newest channel events first and matches tags after can return a short or empty page while matching channels exist.

Choosing one channel

The relay does not make labels unique. Many channels can share a label, and any owner or admin can set any label. So two creates for the same label can both succeed, for example when two processes of one agent start at the same time.

A client that needs one channel per creator and label:

  1. queries {"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]};
  2. creates the channel only if the query returns nothing; and
  3. when the query returns more than one channel, uses the oldest one.

The oldest channel has the lowest created_at tag value, compared as an integer. If two channels have the same value, the lowest channel ID (d tag, compared as a string) wins. A channel without a created_at tag ranks after every channel that has one, and two channels without one compare by channel ID. Every reader that follows this rule picks the same channel, even after either channel is edited.

System channels

A system channel exists only to control who can read some data, for example config that only an agent and its owner can read. It has the channel type system. In every other way it is an ordinary channel: it has members, roles, messages and labels.

Create. A kind:9007 with ["channel_type", "system"] creates a system channel. It is private unless the kind:9007 also has ["visibility", "open"].

Hidden. While a system channel is private, its kind:39000 carries the NIP-29 hidden tag.

Reads that leave system channels out

The relay leaves system channels out of every read that does not name them. Clients that do not know about system channels then stay correct with no change.

A client sees a channel only if it knows the channel's type. A read that does not name a channel returns only the legacy types: stream, forum, dm and workflow, the types that every client knew before this NIP. This list is frozen. The relay leaves out every other type, so a channel type added later is also hidden from clients that do not know it. A filter that names such a channel (see below) reads it under the ordinary rules, and no other filter in the same request gains it. The opt-in in "Asking for system channels" (#t:["system"] or #P) adds system channels only, never a channel of another type. Each new type needs its own way to ask for all its channels. This applies to:

  • a filter without #h, for example the channels or messages a reader can see;
  • kind:39002 #p:["<reader>"], the channels a reader belongs to;
  • COUNT and NIP-50 search; and
  • any relay-specific query API that does the same reads.

A filter names a channel when it has the channel ID in #h, or in #d on a filter whose kinds are all 39000–39003. This is the same for a system channel and for a type added later. Then the ordinary read rules apply: the reader can read the channel if they are a member or if it is open. A client that has the ID, for example from a link, can open the channel. Thus a system channel stays an ordinary NIP-29 group.

The relay decides this for each filter. A filter that names a channel does not add it to another filter in the same request.

An unlisted open channel (Buzz's unlisted setting, see NOSTR.md) is named-only in the same way for readers who are not members, whatever its type. Its members read it like any other channel. The system-channel opt-in never adds it.

Push notifications (NIP-PL) use the same rule. A lease filter wakes its author for an event in a system channel or a channel of a type added later only when the filter names that channel. A lease filter cannot have #t or #P, so here "names" means #h. The relay still checks that the author can read the event, as for every other channel.

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:

{"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

A filter whose kinds are all 39000–39003 also returns the system channels that the reader can read when it has:

  • "#t" with the value system; or
  • any "#P". A query for the channels of named creators returns a small result, so it does not fill generic channel lists.

The filter then matches as usual. A label alone does not ask for system channels. These filters add only system channels. A channel of a type added later reaches only a filter that names it.

To find Filter
My system channels {"kinds": [39002], "#p": ["<me>"], "#t": ["system"]}, then their kind:39000 by #d
Open system channels {"kinds": [39000], "#t": ["system"]}
One creator's channel with a label, system or not {"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]}

"#t": ["system", "<label>"] means system or the label. It does not mean "a system channel with this label". Use #P and the label instead.

Private system channels stay hidden from non-members, as for every private channel.

Advertising support

Until this draft has an upstream integer NIP number, relays MUST NOT advertise it in NIP-11 supported_nips. A relay that supports this NIP adds "nip-cl" to its NIP-11 supported_extensions string array.

A relay without this NIP drops label tags on create and refuses channel_type=system. So a client SHOULD NOT create a channel with labels or a system channel on a relay whose NIP-11 document does not contain "nip-cl". Lookups by label on such a relay find nothing, which is safe.

Trust

A client that relies on these tags MUST check that the relay's key signed the event, as for all NIP-29 group-state events.

Labels give no trust. Any owner or admin can set them. Only P says who created a channel.

Relation to other NIPs

  • NIP-29: the group-state events that this NIP adds tags to.
  • NIP-01: the filters that find channels.
  • NIP-22, NIP-34 and NIP-72: the convention for uppercase P.