feat(relay): add the system channel type (#8180)

🤖
## 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>
This commit is contained in:
Logan Johnson
2026-10-14 19:03:34 -04:00
committed by GitHub
parent a3909acc67
commit 71d7aa0436
14 changed files with 251 additions and 37 deletions

View File

@@ -474,7 +474,7 @@ All database access. Uses `sqlx::query()` (runtime, not compile-time macros) —
| `user.rs` | User profile storage |
| `error.rs` | Database error types |
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`, `System`
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
**Workflow statuses:** `Active`, `Disabled`, `Archived`
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`

View File

@@ -57,7 +57,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
| **Group deletion (kind:9008)** | ✅ | Owner only. |
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels and private system channels |
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) |
| **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members |
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
@@ -109,7 +109,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab
| Kind | Tags | Content |
|------|------|---------|
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels and private system channels) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
| **39001** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags with role label (`owner`, `admin`) | Admin list |
| **39002** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags for all members | Member list |

View File

@@ -157,7 +157,9 @@ pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String {
}
}
}
if declared_type == Some("dm") || is_hidden {
// `hidden` marks a DM only on metadata without a declared type; a private
// system channel is hidden too.
if declared_type == Some("dm") || (declared_type.is_none() && is_hidden) {
"dm".to_string()
} else if declared_type == Some("private") || is_private {
"private".to_string()
@@ -4202,6 +4204,21 @@ mod tests {
assert_eq!(channel_type_from_tags(&tags), "dm");
}
/// Mutation: treat any `hidden` metadata as a DM → RED.
#[test]
fn hidden_means_dm_only_without_a_declared_type() {
let system = vec![
serde_json::json!(["d", "c"]),
serde_json::json!(["t", "system"]),
serde_json::json!(["private"]),
serde_json::json!(["hidden"]),
];
assert_eq!(channel_type_from_tags(&system), "private");
let legacy_dm = vec![serde_json::json!(["d", "c"]), serde_json::json!(["hidden"])];
assert_eq!(channel_type_from_tags(&legacy_dm), "dm");
}
async fn nip11_test_client(
responses: HashMap<String, (u16, String)>,
) -> (

View File

@@ -836,7 +836,9 @@ async fn reconcile_channels(
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
if channel.channel_type == "dm"
|| (channel.channel_type == "system" && channel.visibility == "private")
{
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);

View File

@@ -182,6 +182,10 @@ pub enum ChannelType {
Dm,
/// Internal workflow execution channel.
Workflow,
/// Access-control channel. The relay leaves it out of queries unless a
/// filter names it, or asks for system channels with `#t:["system"]` on
/// kinds 39000-39003.
System,
}
impl ChannelType {
@@ -192,6 +196,17 @@ impl ChannelType {
Self::Forum => "forum",
Self::Dm => "dm",
Self::Workflow => "workflow",
Self::System => "system",
}
}
/// Visibility of a new channel of this type when its create event has no
/// `visibility` tag. A system channel holds access-control data, so it is
/// private unless its creator asks for open.
pub fn default_visibility(&self) -> ChannelVisibility {
match self {
Self::System => ChannelVisibility::Private,
Self::Stream | Self::Forum | Self::Dm | Self::Workflow => ChannelVisibility::Open,
}
}
}
@@ -211,6 +226,7 @@ impl FromStr for ChannelType {
"forum" => Ok(Self::Forum),
"dm" => Ok(Self::Dm),
"workflow" => Ok(Self::Workflow),
"system" => Ok(Self::System),
other => Err(format!("unknown channel type: {other:?}")),
}
}
@@ -309,18 +325,20 @@ mod tests {
/// until its name is in `RESERVED_CHANNEL_LABELS`.
#[test]
fn every_channel_type_name_is_a_reserved_label() {
const ALL: [ChannelType; 4] = [
const ALL: [ChannelType; 5] = [
ChannelType::Stream,
ChannelType::Forum,
ChannelType::Dm,
ChannelType::Workflow,
ChannelType::System,
];
for channel_type in ALL {
match channel_type {
ChannelType::Stream
| ChannelType::Forum
| ChannelType::Dm
| ChannelType::Workflow => {}
| ChannelType::Workflow
| ChannelType::System => {}
}
assert!(
RESERVED_CHANNEL_LABELS.contains(&channel_type.as_str()),

View File

@@ -705,7 +705,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 61);
assert_eq!(migrations.len(), 62);
assert_eq!(migrations[58].version, 59);
assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
assert_eq!(migrations[59].version, 60);
@@ -715,6 +715,11 @@ mod postgres_tests {
.contains("ALTER TABLE push_leases DROP COLUMN app_profile"));
assert_eq!(migrations[60].version, 61);
assert!(migrations[60].sql.as_str().contains("ADD COLUMN labels"));
assert_eq!(migrations[61].version, 62);
assert!(migrations[61]
.sql
.as_str()
.contains("ALTER TYPE channel_type ADD VALUE IF NOT EXISTS 'system'"));
assert_eq!(migrations[55].version, 56);
assert!(migrations[55]
.sql

View File

@@ -38,6 +38,8 @@ pub struct FleetStockSnapshot {
pub channels_dm: i64,
/// Non-deleted workflow channels.
pub channels_workflow: i64,
/// Non-deleted system channels.
pub channels_system: i64,
/// Relay owners.
pub members_owner: i64,
/// Relay administrators.
@@ -96,7 +98,8 @@ where
COUNT(*) FILTER (WHERE channel_type = 'stream') AS stream,
COUNT(*) FILTER (WHERE channel_type = 'forum') AS forum,
COUNT(*) FILTER (WHERE channel_type = 'dm') AS dm,
COUNT(*) FILTER (WHERE channel_type = 'workflow') AS workflow
COUNT(*) FILTER (WHERE channel_type = 'workflow') AS workflow,
COUNT(*) FILTER (WHERE channel_type = 'system') AS system
FROM channels
WHERE deleted_at IS NULL
),
@@ -122,6 +125,7 @@ where
channels.forum AS channels_forum,
channels.dm AS channels_dm,
channels.workflow AS channels_workflow,
channels.system AS channels_system,
members.owner AS members_owner,
members.admin AS members_admin,
members.member AS members_member,
@@ -1455,7 +1459,7 @@ mod postgres_tests {
.await
.expect("deactivate user");
for channel_type in ["stream", "forum", "dm", "workflow"] {
for channel_type in ["stream", "forum", "dm", "workflow", "system"] {
sqlx::query(
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \
VALUES ($1, $2, $3, $4::channel_type, 'open', $5)",
@@ -1589,8 +1593,9 @@ mod postgres_tests {
stock.channels_forum,
stock.channels_dm,
stock.channels_workflow,
stock.channels_system,
),
(1, 1, 1, 1)
(1, 1, 1, 1, 1)
);
assert_eq!(
(

View File

@@ -797,3 +797,139 @@ async fn label_reqs_are_not_starved_by_containment_false_matches() {
"label name"
);
}
/// Whether a group-state tag list has a bare `[name]` marker tag.
fn has_marker(tags: &[Vec<String>], name: &str) -> bool {
tags.iter().any(|tag| tag.len() == 1 && tag[0] == name)
}
/// A system channel is private unless its creator asks for open, and its
/// kind:39000 carries NIP-29 `hidden` exactly while it is private.
/// Mutation: default system channels to open, or drop the `hidden` rule →
/// RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn system_channel_defaults_private_and_hidden() {
let state = state().await;
let tenant = community(&state, "group-state-system").await;
let creator = Keys::generate();
let private = create_channel(&state, &tenant, &creator, &[&["channel_type", "system"]]).await;
let tags = group_state_tags(&state, &tenant, private, 39000).await;
assert!(has_marker(&tags, "private"), "default private: {tags:?}");
assert!(
has_marker(&tags, "hidden"),
"private system is hidden: {tags:?}"
);
assert_eq!(
t_values(&state, &tenant, private, 39000).await.first(),
Some(&"system".to_string())
);
edit_channel(
&state,
&tenant,
&creator,
private,
&[&["visibility", "open"]],
)
.await
.expect("open the channel");
let tags = group_state_tags(&state, &tenant, private, 39000).await;
assert!(has_marker(&tags, "public"), "opened: {tags:?}");
assert!(
!has_marker(&tags, "hidden"),
"open system is not hidden: {tags:?}"
);
let open = create_channel(
&state,
&tenant,
&creator,
&[&["channel_type", "system"], &["visibility", "open"]],
)
.await;
let tags = group_state_tags(&state, &tenant, open, 39000).await;
assert!(has_marker(&tags, "public"), "explicit open: {tags:?}");
assert!(!has_marker(&tags, "hidden"), "{tags:?}");
// Other types keep the open default and are not hidden.
let stream = create_channel(&state, &tenant, &creator, &[]).await;
let tags = group_state_tags(&state, &tenant, stream, 39000).await;
assert!(has_marker(&tags, "public"), "{tags:?}");
assert!(!has_marker(&tags, "hidden"), "{tags:?}");
}
/// A kind:9007 without an `h` tag is created by the side-effect handler, not
/// by the ingest pre-create. That path must give a system channel the same
/// private default, so strangers can neither read nor write it.
/// Mutation: default visibility to open in `handle_create_group` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn system_channel_without_h_tag_defaults_private() {
let state = state().await;
let tenant = community(&state, "group-state-system-no-h").await;
let creator = Keys::generate();
let stranger = Keys::generate();
let name = format!("system-no-h-{}", Uuid::new_v4().simple());
let create = EventBuilder::new(Kind::Custom(9007), "")
.tags([
Tag::parse(["name", &name]).unwrap(),
Tag::parse(["channel_type", "system"]).unwrap(),
])
.sign_with_keys(&creator)
.expect("sign 9007");
let result = ingest_event(&state, &tenant, create, http_auth(&creator))
.await
.unwrap_or_else(|error| panic!("create channel: {error:?}"));
assert!(result.accepted, "{}", result.message);
let (channel_id, visibility): (Uuid, String) = sqlx::query_as(
"SELECT id, visibility::text FROM channels WHERE community_id = $1 AND name = $2",
)
.bind(*tenant.community().as_uuid())
.bind(&name)
.fetch_one(state.db.pool())
.await
.expect("created channel");
assert_eq!(visibility, "private");
let tags = group_state_tags(&state, &tenant, channel_id, 39000).await;
assert!(has_marker(&tags, "private"), "{tags:?}");
assert!(has_marker(&tags, "hidden"), "{tags:?}");
assert!(!has_marker(&tags, "public"), "{tags:?}");
let stranger_bytes = stranger.public_key().to_bytes().to_vec();
assert!(
crate::handlers::ingest::check_channel_membership(
&tenant,
&state,
channel_id,
&stranger_bytes,
None
)
.await
.is_err(),
"a stranger must not pass the channel access check"
);
let readable = state
.db
.get_accessible_channel_ids(tenant.community(), &stranger_bytes)
.await
.expect("accessible channels");
assert!(!readable.contains(&channel_id), "stranger can read it");
let message = EventBuilder::new(Kind::Custom(9), "hello")
.tags([Tag::parse(["h", &channel_id.to_string()]).unwrap()])
.sign_with_keys(&stranger)
.unwrap();
match ingest_event(&state, &tenant, message, http_auth(&stranger)).await {
Ok(result) => assert!(
!result.accepted,
"stranger wrote to a private system channel: {}",
result.message
),
Err(error) => assert!(format!("{error:?}").contains("restricted"), "{error:?}"),
}
}

View File

@@ -3078,17 +3078,6 @@ async fn ingest_event_inner(
// Validate visibility/channel_type for ALL kind:9007 events (with or without h-tag).
// This runs pre-storage so invalid enums are rejected before the event is persisted.
let visibility_str = event
.tags
.iter()
.find_map(|t| {
if t.kind().to_string() == "visibility" {
t.content().map(|s| s.to_string())
} else {
None
}
})
.unwrap_or_else(|| "open".to_string());
let channel_type_str = event
.tags
.iter()
@@ -3100,14 +3089,23 @@ async fn ingest_event_inner(
}
})
.unwrap_or_else(|| "stream".to_string());
let visibility: buzz_db::channel::ChannelVisibility = visibility_str
.parse()
.map_err(|_| IngestError::Rejected(format!("invalid visibility: {visibility_str}")))?;
let channel_type: buzz_db::channel::ChannelType =
channel_type_str.parse().map_err(|_| {
IngestError::Rejected(format!("invalid channel_type: {channel_type_str}"))
})?;
let visibility: buzz_db::channel::ChannelVisibility =
match event.tags.iter().find_map(|t| {
if t.kind().to_string() == "visibility" {
t.content().map(|s| s.to_string())
} else {
None
}
}) {
Some(visibility_str) => visibility_str.parse().map_err(|_| {
IngestError::Rejected(format!("invalid visibility: {visibility_str}"))
})?,
None => channel_type.default_visibility(),
};
let labels = super::side_effects::channel_labels_from_tags(&event)
.transpose()
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?

View File

@@ -1303,8 +1303,12 @@ pub async fn emit_group_discovery_events(
// making channel visibility self-describing for clients.
tags.push(Tag::parse(["public"])?);
}
// NIP-29 hidden tag: hint to clients not to show DMs in public group lists.
// Not a security boundary — access control is handled by channel-scoped storage.
// NIP-29 hidden tag: hide metadata from non-members. Set for DMs and
// for private system channels. Not a security boundary — access
// control is handled by channel-scoped storage.
if channel.channel_type == "system" && channel.visibility == "private" {
tags.push(Tag::parse(["hidden"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
// Include participant pubkeys in kind:39000 for DMs so clients can
@@ -2046,17 +2050,18 @@ async fn handle_create_group(
) -> anyhow::Result<()> {
let name =
extract_tag_value(event, "name").ok_or_else(|| anyhow::anyhow!("missing name tag"))?;
let visibility_str =
extract_tag_value(event, "visibility").unwrap_or_else(|| "open".to_string());
let channel_type_str =
extract_tag_value(event, "channel_type").unwrap_or_else(|| "stream".to_string());
let visibility: buzz_db::channel::ChannelVisibility = visibility_str
.parse()
.map_err(|_| anyhow::anyhow!("invalid visibility: {visibility_str}"))?;
let channel_type: buzz_db::channel::ChannelType = channel_type_str
.parse()
.map_err(|_| anyhow::anyhow!("invalid channel_type: {channel_type_str}"))?;
let visibility: buzz_db::channel::ChannelVisibility =
match extract_tag_value(event, "visibility") {
Some(visibility_str) => visibility_str
.parse()
.map_err(|_| anyhow::anyhow!("invalid visibility: {visibility_str}"))?,
None => channel_type.default_visibility(),
};
let actor_bytes = event.pubkey.to_bytes().to_vec();
let description = extract_tag_value(event, "about");

View File

@@ -2658,6 +2658,7 @@ fn emit_fleet_stock_metrics(snapshot: buzz_db::usage::FleetStockSnapshot) {
("forum", snapshot.channels_forum),
("dm", snapshot.channels_dm),
("workflow", snapshot.channels_workflow),
("system", snapshot.channels_system),
] {
metrics::gauge!("buzz_total_channels", "type" => kind).set(value as f64);
}
@@ -2801,7 +2802,7 @@ async fn emit_db_usage_metrics(
// Zero-fill across all (community, channel_type) pairs so a type that
// drops to zero emits 0 rather than retaining its last nonzero value.
{
const CHANNEL_TYPES: &[&str] = &["stream", "forum", "dm", "workflow"];
const CHANNEL_TYPES: &[&str] = &["stream", "forum", "dm", "workflow", "system"];
let rows: HashMap<(Uuid, &str), i64> = channel_rows
.into_iter()
.filter_map(|r| {

View File

@@ -24,12 +24,16 @@ plain NIP-29, a client cannot:
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`.
1. `["t", <channel type>]`, for example `stream`, `forum`, `dm`, `workflow`, `system`.
2. `["t", <label>]` for each channel label, in stored order (see
[Channel labels](#channel-labels)).
3. `["P", <creator public key, hex>]`.
@@ -149,6 +153,19 @@ 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.
## Trust
A client that relies on these tags MUST check that the relay's key signed the

View File

@@ -0,0 +1,10 @@
-- System channels: channels that exist only for access control, for example
-- to hold config that only an agent and its owner can read. They are ordinary
-- channels in every other way. The relay leaves them out of queries unless a
-- filter names the channel or asks for system channels with #t:["system"]
-- on kinds 39000-39003.
--
-- Additive. Do not roll the relay back past this change while system
-- channels exist: an older relay cannot read the value and does not exclude
-- these channels.
ALTER TYPE channel_type ADD VALUE IF NOT EXISTS 'system';

View File

@@ -1,3 +1,3 @@
-- ── Custom types ──────────────────────────────────────────────────────────────
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow', 'system');