## Summary
Adds an opt-in newest-first order to bridge search, so clients can offer
"Most recent" alongside relevance.
- `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance`
keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by
`created_at DESC, id`, and `page` steps through that order. Newest skips
the exact-profile-lexeme prioritization, which only applies to relevance
ranking.
- Bridge `POST /query`: search filters accept `search_sort: "newest"`.
When the field is missing or has another value, results stay in
relevance order. Community scope, channel intersection and per-hit
authorization are unchanged.
- NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients
only send the field to relays that advertise it, so they never get
relevance results labelled as newest.
- WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the
bridge extension.
### Related issue
N/A
### Testing
- New Postgres integration test
`newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in
newest order, including an equal-timestamp tie split across a page
boundary and a newer hit outside the channel scope. It checks that the
pages join up in `(created_at desc, id asc)` order and that relevance
still ranks the strongest match first.
- Unit tests:
- `bridge_search_sort_extension_selects_newest_only_when_named`
- `advertises_nip_cl_and_search_sort_extensions`
- Ran the buzz-search Postgres integration suite, the relay `nip11` and
`bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p
buzz-search -p buzz-relay --all-targets -D warnings` on Linux.
- Local relay exercised through signed `POST /query`:
- NIP-11 lists `buzz-search-sort`.
- Relevance and newest return different orders.
- Newest pages 1–3 return 25 hits with no gaps or duplicates, including
same-second ties across page boundaries.
- A non-member gets no hits from a private channel; a member gets all of
them.
- Client integration is in a companion block/buzz-app change. It was
exercised end to end against this relay through the production broker:
switching sort, paging, scope changes and capability fallback.
Deployment note: clients only offer newest sorting after a relay with
this change advertises `buzz-search-sort`.
---------
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
🤖
## Summary
After part 2, a client can read a system channel only if it already
knows the ID. This PR lets a client find system channels on purpose,
with an ordinary Nostr filter and no new filter key.
A filter on the channel-state kinds (39000–39003) now also returns the
system channels the reader can read when it has either:
- `#t:["system"]`, the channel type tag that every channel-state event
already carries; or
- any `#P`, the creator tag from #8174. Asking for the channels of a
named creator returns a small result, so it does not fill generic
channel lists.
The filter then matches as usual. Existing clients send neither on these
kinds, so they still never see a system channel.
| Reader wants | 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 labeled channel, system or not | `{kinds:[39000],
#P:[creator], #t:[label]}` |
Private system channels stay hidden from non-members, as for any private
channel. Values in one tag filter combine with OR, so `#t:["system",
label]` means `system` **or** the label; use `#P` plus the label
instead.
Part 3 of 3, stacked on part 2.
### Related issue
None found.
### Details
- The opt-in is decided per filter in `handlers/system_channels.rs` and
applies on REQ, COUNT and the HTTP bridge. It applies only when every
kind in the filter is 39000–39003.
- A label alone is not an opt-in.
- `NOSTR.md` documents the system channel query rules.
- **Spec.** NIP-CL says how a filter asks for system channels.
- The relay information document (NIP-11) now lists `"nip-cl"` in
`supported_extensions`. Clients use this to tell if a relay supports
labels and system channels, and refuse those calls on a relay that does
not. NIP-CL documents this.
### Testing
- End-to-end relay test: `#t:["system"]` finds both system channels for
a member and only the open one for a non-member; 39002 `#p:[me]` with
`#t:["system"]` lists the member's system channels; `#P` includes a
creator's system channels; creator plus label returns exactly one
channel with `limit: 1` and excludes another creator's channel with the
same label; a label alone does not return the system channel; COUNT and
HTTP `/query` and `/count` agree.
- The test fails when the opt-in is disabled.
- Unit tests for which filters opt in.
### 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
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 ← this PR
---------
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
🤖
## Summary
Buzz uses channel membership to decide who can read what, and every
channel a person belongs to shows up in their client as a chat. So there
is no way to make a group that exists only for access control, for
example "only this agent and its owner can read the agent's config".
Such a channel would appear in both people's sidebars, search results
and unread counts.
This stack adds a **system channel**: an ordinary channel that queries
leave out unless they ask for it. This first PR adds the type itself.
- `kind:9007` (create channel) with `["channel_type", "system"]` creates
a system channel.
- It is private unless the creator sends `["visibility", "open"]`.
- While it is private, its channel metadata event (kind:39000) carries
the NIP-29 `hidden` marker, which means "hide this group's metadata from
non-members". Until now the relay set `hidden` only for DMs.
On its own this PR does not yet hide system channels from queries. Part
2 does that.
Part 1 of 3, stacked on #8176 (the channel labels and creator lookup
stack, which this feature uses).
### Related issue
None found.
### Details
- Migration 0062 adds `system` to the `channel_type` Postgres enum. No
new columns.
- `buzz_core::channel::ChannelType::System`, and `system` is now a
reserved label name (labels cannot equal a channel type name).
- `buzz-admin` reconcile writes `hidden` with the same rule as the
relay. Usage metrics count system channels.
- **Rollback.** A relay older than this change cannot read `channel_type
= 'system'`. Do not roll back past it while system channels exist.
- **Spec.** NIP-CL gets a "System channels" section. `ARCHITECTURE.md`
lists the `System` type.
### Testing
- Relay test: a system channel is private and `hidden` by default;
opening it removes `hidden`; its first `t` tag is `system`.
- Migration embed and usage-metrics tests.
### 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
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 ← this PR
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>
🤖
## 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>
🤖
## Summary
Clients cannot tell who created a channel, and only the channel-metadata
event says what type of channel it is. This PR makes every relay-signed
channel-state event carry both facts, so a client can find "the channel
that this key created" with one ordinary Nostr filter.
- Channel metadata, admin list and member list events (kinds 39000,
39001 and 39002) now all carry the channel type as their first `t` tag,
then the creator as a `P` tag.
- The creator comes from the relay's own record of who signed the
channel's create request. No client can set it, and it does not change
when ownership moves.
- Channel metadata (kind:39000) also carries `["created_at", <unix
seconds>]`, the time the channel was created. The event's own
`created_at` changes on every edit, so before this a client could not
tell which of two channels is older.
This is part 1 of a 3-part stack (channel labels and creator lookup).
The system channels stack (#8180–#8182) builds on it.
### Related issue
None found.
### Details
- **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use
uppercase `P` for the author of the root object, and lowercase `p` for
people involved. Lowercase `p` is already used on these events for
members and DM participants, and a filter matches tag values, not their
meaning, so `#p` would mix them.
- **Trust.** A client that trusts a channel because of its creator must
also check that the relay key signed the event.
- **Order.** The type `t` tag comes first, so clients that read the
first `t` tag still get the type after part 2 adds labels as more `t`
tags.
- **Creation time.** The relay reads it from `channels.created_at`, so
an edit does not change it. #8176 uses it to pick one channel when two
creates race.
- `buzz-admin reconcile` writes the same tags when it rebuilds these
events. `NOSTR.md` documents the tags.
- **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule
and lookup by creator. Later PRs in both stacks add their parts to it.
`NOSTR.md` links to it.
### Testing
- Relay tests in `group_state_postgres_tests.rs`: each of the three
kinds carries the type first, then the creator; the creator stays the
same after an ownership transfer; kind:39000 carries the stored creation
time, and still does after an edit.
- Unit test for the shared tag builder that both the relay and
`buzz-admin reconcile` use.
### 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 ←
this PR
2. #8175 channel labels
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>
## Summary
App-hosted agents (buzz-app agents2) run inside the desktop app without
a socket per agent, so they can't send their own kind 20001 presence and
always read as offline — including to their owner on another device.
This adds **kind 20003, an owner's presence for its agents**:
- Owner-signed; `content` is the status (`online` / `away` / `offline`);
each `p` tag names an agent (1–256 distinct).
- WebSocket only (rejected on HTTP ingest, like 20001).
- The relay keeps only the agents the signer owns
(`users.agent_owner_pubkey`) — cached `true` from
`observer_owner_cache`, the rest resolved with one
`list_agents_for_owner` query. Only positive links are cached, so a
just-linked agent isn't hidden for the cache TTL and the observer path
isn't poisoned.
- Sets (180s TTL) or clears each agent's presence in one Redis round
trip, the same keys kind 20001 writes, so the presence snapshot reports
them with no client change.
- Not fanned out: live presence is matched by author, and the author
here is the owner.
A new kind rather than `p` tags on 20001, so a relay without this
ignores the batch instead of storing it as the owner's own presence.
## Testing
- `agent_presence_request_names_distinct_agents_with_a_known_status`
(unit).
-
`presence_storage_postgres_tests::agent_presence_stores_only_owned_agents`
(Postgres + Redis): only the owner's agent goes online, another owner's
agent doesn't, a pre-cached `false` doesn't hide a linked agent, and
`offline` clears it. All 5 presence Postgres tests pass via
`scripts/postgres-test-run.sh -p buzz-relay --lib -E
'test(/presence/)'`.
- `buzz-pubsub` ignored Redis test
`test_presence_many_sets_with_ttl_and_clears` passes.
- clippy clean. Plain `cargo test` failures locally were environmental
only (buzz-media "role buzz does not exist", mesh_demo 504).
- Not yet exercised end to end against a local relay + app.
App side: block/buzz-app#862.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What this fixes
`fan_out_scoped` (`crates/buzz-relay/src/subscription.rs:278-394`)
enforces a deliberate, symmetric scoping invariant — documented in the
code itself:
> Global subscriptions (channel_id = None) do NOT receive channel-scoped
events. Channel-scoped subscriptions do NOT receive global events.
The relay derives a reaction's stored channel from its `#e` target at
ingest — client-supplied `#h` is ignored for channel determination
(`NOSTR.md:50` documents this for *writing*). The consequence for
*reading* is that every reaction is a channel-scoped event, so a live
subscription `{"kinds":[7]}` without `#h` is a global subscription and
**silently receives no reactions at all** — no error, no CLOSED, just
nothing. The working form is `{"kinds":[7],"#h":["<channel-uuid>"]}`,
and it works regardless of how the reaction was signed: explicit `h`
tags on the event are matched directly, and tagless reactions match via
the stored channel fallback (`crates/buzz-core/src/filter.rs:78-91` —
fallback applies only when the event has no `h` tags; explicit tags are
authoritative).
`NOSTR.md` already documents this exact pitfall for group-metadata
events:
> **Note:** Channel-scoped storage means live global subscriptions
(`{kinds:[39000]}`) won't receive these via fan-out.
(`NOSTR.md:124-126`)
…but has no equivalent note for reactions, which is the case a
bot/integration author is far more likely to hit: any client that wants
to observe approvals/reactions live (workflow reaction-triggers make
this a first-class pattern in Buzz) will naturally try a kinds-only REQ
first and conclude reactions are broken. We lost real debugging time to
exactly this while building a headless integration
(https://github.com/OriginTrail/buzz-dkg-integration); the behavior is
by design, only the docs are missing.
## What this PR changes
Docs only (`NOSTR.md`): a subscribe-to-reactions example in "Sending
Messages", plus one note mirroring the existing 39000 note. No code
changes.
## How to verify
- Behavior: with the relay running, open a live REQ `{"kinds":[7]}` (no
`#h`) and react to a channel message from another client → nothing is
delivered; re-subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` →
the reaction arrives.
- Claims against code (verified at `485d03a`): scoping invariant
`crates/buzz-relay/src/subscription.rs:386-393`; channel derivation
`derive_reaction_channel()` in
`crates/buzz-relay/src/handlers/ingest.rs`; `#h` fallback
`crates/buzz-core/src/filter.rs:78-91` and its test
`h_tag_fallback_uses_stored_channel_id`.
Duplicate search: no existing issue/PR found for `reactions
subscription`, `fan-out kinds` (searched 2026-07-29). DCO signed-off.
---------
Signed-off-by: Žiga Drev <ziga.drev@gmail.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: Žiga Drev <ziga.drev@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
## Problem
Three small documentation defects, each verified against the code at
06e3d82b:
1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines
"all 81 kinds". The registry has grown: `ALL_KINDS` in
`crates/buzz-core/src/kind.rs` now has **127** entries (all unique
values). The sentence also says every kind is `pub const KIND_*`, but
registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that
prefix.
2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no
step 4. PR #797 (2a038515) collapsed the old steps 1-4 (dropping the
separate "Start infrastructure" step) into 1-3, but the final "Connect
any NIP-29 + NIP-42 client" comment kept its old number 5.
3. **NOSTR.md "Further Reading"** is an empty heading — the section's
only content (a link to `crates/buzz-proxy/README.md`) was removed in PR
#1321 (14fba21e) along with the proxy crate itself, leaving a dangling
header as the last line of the file.
## Fix
1. Reworded the ARCHITECTURE.md sentence to defer to
`crates/buzz-core/src/kind.rs` as the source of truth, with the current
count (127) as an explicit "at the time of writing" snapshot, so the
sentence stays truthful as kinds are added. Also removed the incorrect
`KIND_*`-naming claim.
2. Renumbered the final quick-start step 5 → 4.
3. Populated Further Reading with three durable links: the upstream
nostr-protocol/nips repo, this repo's `docs/nips/` extension documents,
and `ARCHITECTURE.md`.
Docs-only; no code changes, no build impact.
## Verification (each claim ~30 seconds)
- Kind count: `python3 -c "import re;
s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS:
&\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in
m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are
distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs,
in `ALL_KINDS`).
- Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `#
2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a038515 --
NOSTR.md` shows the renumbering that orphaned step 5.
- Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with
nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md`
shows the content removal in 14fba21e (#1321).
## Links
- `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth
for the count)
- PR #797 / 2a038515 — introduced the step-numbering gap
- PR #1321 / 14fba21e — emptied the Further Reading section
Signed-off-by: Sean Gearin <sgearin@gmail.com>
Co-authored-by: Sean Gearin <sgearin@gmail.com>