feat(relay): channel labels (#8175)

🤖
## Summary
A plugin or agent often needs to tag its own channels and then find them
again, for example "my attention-config channel" or "all channels of
this workspace". Today a channel has no labels: the relay ignores any
`t` tags in create or edit requests. This PR adds channel labels.

- A create request can carry up to 8 labels as `["t", "<label>"]` tags.
- An edit request with `t` tags replaces the label set. `["t", ""]`
alone clears it, the same convention as `["ttl", ""]`. Only owners and
admins can change labels, as for the name.
- The relay publishes each label as a `t` tag on the channel's state
events, after the channel type tag.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- **Rules.** A label uses 1–64 characters from `a-z`, `0-9`, `.`, `:`
and `-`. The relay refuses a label equal to a channel type name
(`stream`, `forum`, `dm`, `workflow`, `system`), so a label cannot make
a channel look like another type.
- **No trust from labels.** Any admin can set a label. Only the
relay-written creator tag (part 1) says who made a channel.
- **Storage.** New migration `0061_channel_labels.sql` adds
`channels.labels TEXT[] NOT NULL DEFAULT '{}'`. Channels without labels
look and behave as before.
- **Readers.** The CLI and `buzz-acp` read the channel type from the
first `t` tag. Before this PR they used the last one, which would now be
a label.
- **Spec.** NIP-CL gets a "Channel labels" section with these rules.

### Testing
- Relay tests: labels at creation appear after the type tag, edit
replaces and clears them, invalid labels are refused, and members who
are not admins cannot change them.
- Unit tests for the label rules: allowed characters, length, reserved
type names, the 8-label cap and the clear marker.
- `buzz-acp` test: the channel type comes from the first `t` tag, not a
label.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels ← this PR
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels

---------

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-14 17:25:46 -04:00
committed by GitHub
parent b3795236cc
commit 2f79374f48
21 changed files with 769 additions and 57 deletions

View File

@@ -192,7 +192,8 @@ impl ChannelSummary {
match key {
"d" => channel_id = val.map(str::to_string),
"name" => name = val.map(str::to_string),
"t" => channel_type = val.map(str::to_string),
// The first `t` is the channel type; later `t` tags are labels.
"t" if channel_type.is_none() => channel_type = val.map(str::to_string),
// NIP-29 emits both `private` and `public` (Buzz adds the latter).
// The presence of either tag is the source of truth; tag value is unused.
"private" => visibility = Some("private".to_string()),
@@ -2008,6 +2009,19 @@ mod tests {
}
}
#[test]
fn from_event_reads_the_type_from_the_first_t_tag() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "labeled"],
["t", "forum"],
["t", "workspace"],
["P", "ab".repeat(32)],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.channel_type.as_deref(), Some("forum"));
}
#[test]
fn from_event_extracts_known_tags() {
let ev = event(json!([