From 71d7aa04369a6e86e158dc6d46246c8a3ce6d166 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 19:03:34 -0400 Subject: [PATCH] feat(relay): add the system channel type (#8180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## 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 Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- ARCHITECTURE.md | 2 +- NOSTR.md | 4 +- crates/buzz-acp/src/relay.rs | 19 ++- crates/buzz-admin/src/main.rs | 4 +- crates/buzz-core/src/channel.rs | 22 ++- crates/buzz-db/src/runtime/migration.rs | 7 +- crates/buzz-db/src/store/usage.rs | 11 +- .../handlers/group_state_postgres_tests.rs | 136 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 28 ++-- .../buzz-relay/src/handlers/side_effects.rs | 21 +-- crates/buzz-relay/src/main.rs | 3 +- docs/nips/NIP-CL.md | 19 ++- migrations/0062_system_channel_type.sql | 10 ++ schema/types/channel_type.sql | 2 +- 14 files changed, 251 insertions(+), 37 deletions(-) create mode 100644 migrations/0062_system_channel_type.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e0875618dd..633db903c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` diff --git a/NOSTR.md b/NOSTR.md index bb9b911017..8b4a1a8e9f 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -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", ]`, `["P", ]` and `["created_at", ]` 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", ]`, `["P", ]` and `["created_at", ]` 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=`, `name`, `closed`, `t=`, `P=`, `created_at=` (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=`, `name`, `closed`, `t=`, `P=`, `created_at=` (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=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 5587682d0b..0a99aeeb71 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -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, ) -> ( diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 736b8d4f12..7fca365e6f 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -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"])?); diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 0a40dca926..e9b68c17af 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -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()), diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 8c40861900..0e023340a3 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -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 diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index f2d7f996aa..f8ad1facbf 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -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!( ( diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index 22110190b0..ed4d862b0a 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -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], 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:?}"), + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a07e2054c1..de4a19217d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -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}")))? diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 8a7b580a2a..f3dbc8a875 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -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"); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 4ed127dfff..7c1b607840 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -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| { diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 81cf165c21..844d89a3a2 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -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", ]`, for example `stream`, `forum`, `dm`, `workflow`. +1. `["t", ]`, for example `stream`, `forum`, `dm`, `workflow`, `system`. 2. `["t",