From 25b05719505a69d7b86650562b0824a63cf61efa Mon Sep 17 00:00:00 2001 From: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:45:39 -0800 Subject: [PATCH 1/2] fix(relay): expose role-independent agent identity Publish optional agent markers from community ownership in signed rosters. Reuse the bounded enrollment refresh for every active role while keeping permission roles and owner attribution unchanged. Co-authored-by: Codex Signed-off-by: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> --- NOSTR.md | 17 ++- crates/buzz-db/src/store/channel_members.rs | 116 +++++++++++++---- .../postgres_tests/owner_admission_tests.rs | 50 ++++++-- crates/buzz-relay/src/handlers/auth.rs | 118 ++++++++++++++++-- .../handlers/group_state_postgres_tests.rs | 104 +++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 20 +-- docs/nips/NIP-CL.md | 15 +++ 7 files changed, 381 insertions(+), 59 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index 8eb1c7fbf16..a52320fcd8b 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -112,9 +112,11 @@ relay to specific external Nostr identities without granting full access. - The relay shows an ordinary member as `bot` if the key has a recorded agent owner, and as `member` otherwise, in kind:39002 rosters and every other place it reports a role. -- An agent owner is recorded once, by the first NIP-OA authentication, and - never changes. When it is recorded, the relay republishes the kind:39002 - rosters of that key's channels so they show `bot`. +- An agent owner is recorded once from verified NIP-OA or NIP-FI authentication, + or an owner-attested invite claim, and never changes. When it is recorded, + the relay republishes the kind:39002 + rosters of that key's channels so they carry agent evidence. Ordinary + memberships also show `bot`; other roles stay unchanged. - `owner`, `admin` and `guest` are stored and shown as asked. Clients follow three rules: @@ -123,8 +125,13 @@ Clients follow three rules: from older clients and stores the same role it would store for `member`. - **Parse:** read a `bot` role in a kind:39002 roster or kind:9000 as `member` as soon as it is parsed. Do not keep `bot` as a separate client role. -- **Identity:** never infer that a key is an agent from its channel role. A - key is an agent only when a verified NIP-OA attestation says so. +- **Identity:** never infer that a key is an agent from its channel role. + A verified NIP-OA attestation or an `agent` tag in the selected relay-signed + kind:39002 roster supplies positive agent evidence. The roster tag reflects + recorded ownership in this community, including for owners, admins and guests. + It discloses no owner and grants no permissions; public owner attribution + still requires a verified NIP-OA attestation. Missing tags mean unknown, + including on older snapshots. See [NIP-CL](docs/nips/NIP-CL.md). ### Announce channels diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 671158c5b51..384a109a27f 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -406,6 +406,9 @@ pub struct ChannelIdentity { pub struct LockedMemberSnapshot { /// Canonical active members captured behind the lock. pub members: Vec, + /// Active members with a recorded agent owner in this community. + /// This is positive evidence only; absence does not establish a human. + pub agent_pubkeys: Vec>, /// The channel's type, labels and creator, read on the same connection, so the /// roster carries the same identity tags as the channel's other /// group-state events. `None` when no channel row exists. @@ -592,8 +595,13 @@ pub async fn lock_member_snapshot( let rows = sqlx::query(concat!( "SELECT cm.channel_id, cm.pubkey, ", member_role_label!(), - " AS role, cm.joined_at, cm.invited_by, cm.removed_at", + " AS role, cm.joined_at, cm.invited_by, cm.removed_at,", r#" + EXISTS ( + SELECT 1 FROM users u + WHERE u.community_id = cm.community_id AND u.pubkey = cm.pubkey + AND u.agent_owner_pubkey IS NOT NULL + ) AS is_agent FROM channel_members cm JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL @@ -604,9 +612,15 @@ pub async fn lock_member_snapshot( .bind(channel_id) .fetch_all(tx.conn()) .await?; + let mut agent_pubkeys = Vec::new(); let members = rows .into_iter() - .map(row_to_member_record) + .map(|row| { + if row.try_get::("is_agent")? { + agent_pubkeys.push(row.try_get("pubkey")?); + } + row_to_member_record(row) + }) .collect::>>()?; let identity = sqlx::query_as::<_, (String, Vec, Vec)>( "SELECT channel_type::text, labels, created_by FROM channels WHERE community_id = $1 AND id = $2", @@ -622,6 +636,7 @@ pub async fn lock_member_snapshot( }); Ok(LockedMemberSnapshot { members, + agent_pubkeys, identity, channel_id, relay_pubkey: relay_pubkey.to_vec(), @@ -1825,10 +1840,9 @@ pub async fn list_large_channel_rosters_needing_reconciliation( .collect() } -/// The channels where `pubkey` is an active ordinary member (`member` or -/// `bot`). Recording an agent owner changes the label these rows show, so -/// their rosters are republished. -pub async fn list_ordinary_member_channels( +/// The nondeleted channels where `pubkey` is an active member of any role. +/// Recording an agent owner adds a roster trait even when the role stays the same. +pub async fn list_active_member_channels( pool: &PgPool, community_id: CommunityId, pubkey: &[u8], @@ -1842,7 +1856,7 @@ pub async fn list_ordinary_member_channels( "SELECT cm.channel_id FROM channel_members cm \ JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id \ AND c.deleted_at IS NULL \ - WHERE cm.community_id = $1 AND cm.pubkey = $2 AND cm.role IN ('member', 'bot') \ + WHERE cm.community_id = $1 AND cm.pubkey = $2 \ AND cm.removed_at IS NULL \ ORDER BY cm.channel_id", ) @@ -2529,14 +2543,14 @@ impl Db { .await } - /// Returns the channels where `pubkey` is an active ordinary member. - #[datastore_span(name = "list_ordinary_member_channels", system = "postgresql")] - pub async fn list_ordinary_member_channels( + /// Returns the nondeleted channels where `pubkey` is an active member of any role. + #[datastore_span(name = "list_active_member_channels", system = "postgresql")] + pub async fn list_active_member_channels( &self, community_id: CommunityId, pubkey: &[u8], ) -> Result> { - list_ordinary_member_channels(&self.pool, community_id, pubkey).await + list_active_member_channels(&self.pool, community_id, pubkey).await } /// Returns full channel records for all channels a user can access. @@ -5934,6 +5948,9 @@ mod postgres_tests { nostr::Tag::parse(["p", &hex::encode(&m.pubkey), "", &m.role]) .expect("p tag") })) + .chain(snapshot.agent_pubkeys.iter().map(|key| { + nostr::Tag::parse(["agent", &hex::encode(key)]).expect("agent tag") + })) .collect::>(); nostr::EventBuilder::new(nostr::Kind::Custom(39002), "") .tags(tags) @@ -5965,6 +5982,7 @@ mod postgres_tests { .lock_member_snapshot(community, channel, &relay_pubkey) .await .expect("capture roster"); + assert!(snapshot.agent_pubkeys.is_empty()); let captured = roster(&snapshot, at); // The owner record does not take the membership lock, so it commits // while the publisher holds it. @@ -5990,20 +6008,26 @@ mod postgres_tests { .lock_member_snapshot(community, channel, &relay_pubkey) .await .expect("capture roster for republish"); + assert_eq!(snapshot.agent_pubkeys, vec![agent.clone()]); let republished = roster(&snapshot, at + 1); snapshot .replace_member_event(&republished) .await .expect("republish"); snapshot.release().await.expect("commit republish"); - assert_eq!(agent_label(&live_tags().await).as_deref(), Some("bot")); + let tags = live_tags().await; + assert_eq!(agent_label(&tags).as_deref(), Some("bot")); + assert!(tags + .as_array() + .expect("tags") + .contains(&serde_json::json!(["agent", hex::encode(&agent)]))); } - /// The republish after an owner record lists the agent's active ordinary - /// memberships only. + /// Republish candidates cover every active role in this community, but not + /// removed memberships or deleted channels. #[tokio::test] #[ignore = "requires Postgres"] - async fn ordinary_member_channels_list_only_active_ordinary_rows() { + async fn active_member_channels_exclude_removed_deleted_and_other_communities() { let pool = setup_pool().await; let community_id = make_test_community(&pool).await; let community = CommunityId::from_uuid(community_id); @@ -6014,25 +6038,67 @@ mod postgres_tests { .await .expect("ensure user"); } - let member = open_test_channel(&pool, community_id, &creator).await; - let removed = open_test_channel(&pool, community_id, &creator).await; - let guest = open_test_channel(&pool, community_id, &creator).await; - for channel in [member, removed] { - add_member(&pool, community, channel, &agent, MemberRole::Member, None) + let mut expected = Vec::new(); + for role in [ + MemberRole::Member, + MemberRole::Bot, + MemberRole::Admin, + MemberRole::Owner, + MemberRole::Guest, + ] { + let channel = open_test_channel(&pool, community_id, &creator).await; + add_member(&pool, community, channel, &agent, role, Some(&creator)) .await - .expect("add agent"); + .expect("add active agent"); + expected.push(channel); } - add_member(&pool, community, guest, &agent, MemberRole::Guest, None) + let removed = open_test_channel(&pool, community_id, &creator).await; + let deleted = open_test_channel(&pool, community_id, &creator).await; + for channel in [removed, deleted] { + add_member( + &pool, + community, + channel, + &agent, + MemberRole::Admin, + Some(&creator), + ) .await - .expect("add agent as guest"); + .expect("add excluded agent"); + } remove_member(&pool, community, removed, &agent, &agent) .await .expect("agent leaves"); + sqlx::query("UPDATE channels SET deleted_at = now() WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(deleted) + .execute(&pool) + .await + .expect("delete channel"); + let other_id = make_test_community(&pool).await; + let other = CommunityId::from_uuid(other_id); + for key in [&creator, &agent] { + ensure_user(&pool, other, key) + .await + .expect("ensure other user"); + } + let other_channel = open_test_channel(&pool, other_id, &creator).await; + add_member( + &pool, + other, + other_channel, + &agent, + MemberRole::Admin, + Some(&creator), + ) + .await + .expect("add agent in other community"); + expected.sort(); assert_eq!( - list_ordinary_member_channels(&pool, community, &agent) + list_active_member_channels(&pool, community, &agent) .await .expect("list channels"), - vec![member] + expected ); } } diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs index 38fa98e0376..4a03fcd27e6 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs @@ -256,6 +256,7 @@ mod postgres_tests { use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; let (state, host, community, issuer) = fixture().await; + let tenant = buzz_core::tenant::TenantContext::resolved(community, &host); let v2 = mint_code( state.clone(), &host, @@ -274,7 +275,12 @@ mod postgres_tests { .await .expect("seed agent user"); let mut channels = Vec::new(); - for name in ["claim-roster-a", "claim-roster-b"] { + for (name, role) in [ + ("claim-member", MemberRole::Member), + ("claim-admin", MemberRole::Admin), + ("claim-owner", MemberRole::Owner), + ("claim-guest", MemberRole::Guest), + ] { let channel = state .db .create_channel( @@ -295,12 +301,22 @@ mod postgres_tests { community, channel, &agent_bytes, - MemberRole::Member, + role, Some(&issuer.public_key().to_bytes()), ) .await .expect("add unowned agent"); - channels.push(channel); + crate::handlers::side_effects::emit_group_discovery_events( + &tenant, &state, channel, + ) + .await + .expect("publish before claim"); + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + assert!(!tags.iter().any(|tag| tag[0] == "agent")); + channels.push((channel, role)); } // An owner check before the claim cached "not owner". @@ -338,7 +354,7 @@ mod postgres_tests { let agent_hex = agent.public_key().to_hex(); let deadline = std::time::Instant::now() + Duration::from_secs(10); - for channel in channels { + for (channel, role) in channels { loop { let live: Option = sqlx::query_scalar( "SELECT tags FROM events WHERE community_id = $1 AND channel_id = $2 \ @@ -349,20 +365,34 @@ mod postgres_tests { .fetch_optional(state.db.pool()) .await .expect("read live roster"); - let shows_bot = + let shows_agent = live.as_ref() .and_then(|tags| tags.as_array()) .is_some_and(|tags| { - tags.iter().any(|tag| { - tag[0] == "p" && tag[1] == agent_hex.as_str() && tag[3] == "bot" - }) + tags.iter() + .any(|tag| tag[0] == "agent" && tag[1] == agent_hex.as_str()) }); - if shows_bot { + if shows_agent { + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + let expected = if role == MemberRole::Member { + "bot" + } else { + role.as_str() + }; + assert!(tags.contains(&vec![ + "p".into(), + agent_hex.clone(), + "".into(), + expected.into() + ])); break; } assert!( std::time::Instant::now() < deadline, - "code {code}: the roster must show the claimant as bot: {live:?}" + "code {code}: the roster must identify the claimant as an agent: {live:?}" ); tokio::time::sleep(Duration::from_millis(50)).await; } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 4536e462a5e..046816d5608 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2686,12 +2686,29 @@ mod tests { community, channel, &agent.public_key().to_bytes(), - MemberRole::Member, + MemberRole::Admin, Some(&creator.public_key().to_bytes()), ) .await .expect("add unowned agent"); + let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-lock"); + crate::handlers::side_effects::emit_group_discovery_events( + &conn.tenant, + &state, + channel, + ) + .await + .expect("publish before enrollment"); + let before_tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, + &conn.tenant, + channel, + 39002, + ) + .await; + assert!(!before_tags.iter().any(|tag| tag[0] == "agent")); + // Hold every republish permit and the roster lock the republish // needs. let permits = Arc::clone(&state.roster_republish_semaphore) @@ -2708,7 +2725,6 @@ mod tests { let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") .expect("sign NIP-OA credential"); let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("tag JSON"); - let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-lock"); tokio::time::timeout( std::time::Duration::from_secs(2), handle_auth( @@ -2746,13 +2762,12 @@ mod tests { .expect("read live roster"); live }; - let shows_bot = |live: &Option| { + let shows_agent = |live: &Option| { live.as_ref() .and_then(|tags| tags.as_array()) .is_some_and(|tags| { - tags.iter().any(|tag| { - tag[0] == "p" && tag[1] == agent_hex.as_str() && tag[3] == "bot" - }) + tags.iter() + .any(|tag| tag[0] == "agent" && tag[1] == agent_hex.as_str()) }) }; @@ -2763,23 +2778,31 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(500)).await; let waiting = live_roster().await; assert_eq!(waiting, before, "no republish runs without a permit"); - assert!(!shows_bot(&waiting), "{waiting:?}"); + assert!(!shows_agent(&waiting), "{waiting:?}"); - // Once a permit is free, the background republish names the agent - // `bot` in the live roster. + // Once a permit is free, the roster gains an agent trait while + // preserving the agent's admin authority. drop(permits); let deadline = Instant::now() + std::time::Duration::from_secs(10); loop { let live = live_roster().await; - if shows_bot(&live) { + if shows_agent(&live) { break; } assert!( Instant::now() < deadline, - "the roster must converge to bot after the lock is released: {live:?}" + "the roster must identify the agent after the lock is released: {live:?}" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, + &conn.tenant, + channel, + 39002, + ) + .await; + assert!(tags.contains(&vec!["p".into(), agent_hex, "".into(), "admin".into()])); } /// An untagged AUTH that read a NULL stored owner just before a @@ -4061,6 +4084,57 @@ mod tests { Case::MemberOwner | Case::NonMemberOwner => {} } + // A private FI owner assertion must refresh an existing elevated + // membership without requiring any public profile or NIP-OA tag. + let channel = if matches!(case, Case::MemberOwner) { + use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; + for key in [agent, owner.public_key()] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + let channel = state + .db + .create_channel( + community, + "fi-agent-roster", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .expect("create channel") + .id; + state + .db + .add_member( + community, + channel, + agent.as_bytes(), + MemberRole::Admin, + Some(owner.public_key().as_bytes()), + ) + .await + .expect("add unowned admin"); + crate::handlers::side_effects::emit_group_discovery_events( + &tenant, &state, channel, + ) + .await + .expect("publish before FI enrollment"); + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + assert!(!tags.iter().any(|tag| tag[0] == "agent")); + Some(channel) + } else { + None + }; + harness.run(harness.auth_event(), state.clone()).await; if matches!(case, Case::MemberOwner) { @@ -4079,6 +4153,28 @@ mod tests { .is_agent_owner(community, agent.as_bytes(), owner.public_key().as_bytes()) .await .expect("read stored owner")); + let channel = channel.expect("member owner has a prior roster"); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + if tags.contains(&vec!["agent".into(), agent.to_hex()]) { + assert!(tags.contains(&vec![ + "p".into(), + agent.to_hex(), + "".into(), + "admin".into() + ])); + break; + } + assert!( + Instant::now() < deadline, + "FI enrollment must refresh the existing roster" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } } else { harness.assert_fi_terminal(buzz_auth::DenialClass::AuthorizationDenied); } 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 0e3859c05b9..733f3f6115f 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -94,6 +94,11 @@ pub(crate) async fn group_state_tags( let event = events .first() .unwrap_or_else(|| panic!("no kind:{kind} for {channel_id}")); + assert_eq!(event.event.pubkey, state.relay_keypair.public_key()); + event + .event + .verify() + .expect("valid relay-signed group state"); event .event .tags @@ -197,6 +202,105 @@ async fn group_state_events_carry_type_then_creator() { } } +/// Ownership classifies agents across roles without editable profile hints. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_members_publish_recorded_agent_type_independent_of_role() { + let state = state().await; + let creator = Keys::generate(); + let agent = Keys::generate(); + let agent_bytes = agent.public_key().to_bytes(); + let agent_hex = agent.public_key().to_hex(); + for owned in [true, false] { + let tenant = community(&state, "group-state-agent").await; + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + state + .db + .ensure_user(tenant.community(), &agent_bytes) + .await + .unwrap(); + if owned { + state + .db + .ensure_user(tenant.community(), &creator.public_key().to_bytes()) + .await + .unwrap(); + state + .db + .set_agent_owner( + tenant.community(), + &agent_bytes, + &creator.public_key().to_bytes(), + ) + .await + .unwrap(); + } + let roles: &[&str] = if owned { + &["member", "admin", "owner", "guest"] + } else { + &["bot"] + }; + for role in roles { + let add = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["p", &agent_hex]).unwrap(), + Tag::parse(["role", role]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + let result = ingest_event(&state, &tenant, add, http_auth(&creator)) + .await + .expect("change role"); + assert!(result.accepted); + let tags = group_state_tags(&state, &tenant, channel_id, 39002).await; + let traits: Vec<_> = tags + .iter() + .filter(|tag| tag[0] == "agent") + .cloned() + .collect(); + assert_eq!( + traits, + if owned { + vec![vec!["agent".to_string(), agent_hex.clone()]] + } else { + vec![] + } + ); + assert!(tags.contains(&vec![ + "p".to_string(), + agent_hex.clone(), + "".to_string(), + match *role { + "member" | "bot" => + if owned { + "bot" + } else { + "member" + }, + role => role, + } + .to_string() + ])); + } + let remove = EventBuilder::new(Kind::Custom(9001), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["p", &agent_hex]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + let result = ingest_event(&state, &tenant, remove, http_auth(&creator)) + .await + .expect("remove member"); + assert!(result.accepted); + let tags = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!(!tags + .iter() + .any(|tag| (tag[0] == "agent" || tag[0] == "p") && tag[1] == agent_hex)); + } +} + /// The creator tag names the kind:9007 signer, not the current owner. /// Mutation: derive `P` from the owner role → RED after the transfer. #[tokio::test] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d55a02b748b..b0662a98f50 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1366,8 +1366,9 @@ fn group_members_tags( group_id: &str, identity: Option<&buzz_db::channel::ChannelIdentity>, members: &[MemberRecord], + agent_pubkeys: &[Vec], ) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 3); + let mut tags: Vec = Vec::with_capacity(members.len() + agent_pubkeys.len() + 3); tags.push(Tag::parse(["d", group_id])?); if let Some(identity) = identity { tags.extend(group_state_identity_tags(identity)?); @@ -1378,6 +1379,9 @@ fn group_members_tags( // because the canonical relay is implicit (this event is signed by it). tags.push(Tag::parse(["p", &pubkey_hex, "", &member.role])?); } + for key in agent_pubkeys { + tags.push(Tag::parse(["agent", &hex::encode(key)])?); + } Ok(tags) } @@ -1390,6 +1394,7 @@ async fn store_group_members_event( &group_id, member_snapshot.identity.as_ref(), &member_snapshot.members, + &member_snapshot.agent_pubkeys, )?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4147,9 +4152,8 @@ async fn republish_roster_candidates( } /// Republish, in the background, the kind:39002 roster of every channel -/// where `agent` is an active ordinary member. Call it after recording the -/// agent's owner: rosters show `bot` for an owned agent, so each of those -/// rosters now shows the wrong label. +/// where `agent` is an active member. Call it after recording the agent's +/// owner: every role gains an agent trait, and ordinary members show `bot`. /// /// The channels are listed inside the task, after the owner record has /// committed, so a channel the agent joins concurrently is either listed here @@ -4158,14 +4162,14 @@ async fn republish_roster_candidates( /// writer. That lock can wait up to the lock timeout, so the caller does not /// wait: first AUTH must finish inside its own deadline. A failure is logged /// and counted and does not stop the other channels. A roster left stale (for -/// example by a relay that stopped first) shows only the wrong label, which -/// has the same permissions; the next membership change repairs it. +/// example by a relay that stopped first) lacks the new trait or role label; +/// permissions stay the same, and the next membership change repairs it. pub fn spawn_agent_roster_republish(tenant: &TenantContext, state: Arc, agent: Vec) { let (community_id, host) = (tenant.community(), tenant.host().to_owned()); tokio::spawn(async move { let channels = match state .db - .list_ordinary_member_channels(community_id, &agent) + .list_active_member_channels(community_id, &agent) .await { Ok(channels) => channels, @@ -4752,7 +4756,7 @@ mod tests { labels: Vec::new(), created_by: vec![0xab; 32], }; - let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members) + let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members, &[]) .expect("build tags"); assert_eq!( tags.len(), diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 27ed3a8a400..55ec3452383 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -81,6 +81,21 @@ 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. +**Agent members.** A kind:39002 roster MAY carry `["agent", ""]` +for each active member with a recorded agent owner in this community. This is +positive identity evidence, independent of the member's channel role and editable +profile. It does not disclose the owner, establish public ownership attribution, +or grant permissions. The four-field `p` tags remain unchanged. Clients accept an +agent tag only from the selected relay-signed roster, with exactly two fields and +a lowercase 64-character hexadecimal key present in that roster. + +These traits are captured when the roster is published, including on membership +or role changes. First recording an owner schedules republication for all active +channel memberships, using the existing bounded roster publisher. This does not +scan older snapshots on upgrade. A failed refresh or older relay may leave a +snapshot without traits until its next normal publication. Missing traits mean +unknown, not human. + **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 From cb35d4b5e87d68ec2da8c16f70dcfbd4a4c368e4 Mon Sep 17 00:00:00 2001 From: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:57:29 -0800 Subject: [PATCH 2/2] fix(hooks): check overlap against the configured PR base Use the same branch base setting as GitHub CLI for stacked PRs. Fetch the target before comparing and reject unavailable or invalid bases. Keep the remaining push checks unchanged. Co-authored-by: Codex Signed-off-by: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + CONTRIBUTING.md | 6 +++ scripts/check-branch-skew.sh | 26 +++++++----- scripts/test-branch-skew.sh | 78 ++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100755 scripts/test-branch-skew.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91943dc02c2..0546c051af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,8 @@ jobs: run: | scripts/test-postgres-test-discovery.sh scripts/test-postgres-test-wrapper.sh + - name: PR base overlap guard + run: scripts/test-branch-skew.sh - name: Release workflow source contract run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e392a8b252f..acaf252b727 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,12 @@ This installs a `commit-msg` hook that adds the sign-off trailer automatically f We review as capacity allows — focused PRs that follow this guide move fastest. +For a PR based on another branch, set GitHub CLI's `branch..gh-merge-base` +Git setting to that base branch. The pre-push overlap guard uses this setting +(default: `main`) and requires a successful fetch from `origin`. Keep it aligned +with the PR's actual base, including after retargeting. Other local test lanes +still select changes against `origin/main` and can run broader checks. + --- ## Setting Up the Development Environment diff --git a/scripts/check-branch-skew.sh b/scripts/check-branch-skew.sh index 0c75126ccfe..3480f11ee44 100755 --- a/scripts/check-branch-skew.sh +++ b/scripts/check-branch-skew.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Pre-push guard: CI checks the PR merged with main, so local runs on a -# skewed branch can pass while CI fails. Block the push only when -# origin/main has changed files this branch also touches. +# Pre-push guard: CI checks the PR merged with its base. Use gh's configured +# PR base for stacked branches, defaulting to main. Block when that base has +# changed files this branch also touches. set -euo pipefail branch=$(git rev-parse --abbrev-ref HEAD) @@ -9,16 +9,22 @@ if [ "$branch" = "main" ] || [ "$branch" = "HEAD" ]; then exit 0 fi -git fetch --quiet origin main || true -git rev-parse --verify --quiet origin/main >/dev/null || exit 0 +target=$(git config --get "branch.$branch.gh-merge-base") || target=main +if ! git check-ref-format "refs/heads/$target" || [ "$target" = "$branch" ]; then + echo "Invalid PR base for $branch: '$target'" >&2 + exit 1 +fi +target_ref="refs/remotes/origin/$target" +git fetch --quiet origin "+refs/heads/$target:$target_ref" +target_oid=$(git rev-parse --verify "$target_ref^{commit}") -base=$(git merge-base HEAD origin/main) -if [ "$base" = "$(git rev-parse origin/main)" ]; then +base=$(git merge-base HEAD "$target_oid") +if [ "$base" = "$target_oid" ]; then exit 0 fi overlap=$(comm -12 \ - <(git diff --name-only "$base" origin/main -- | sort) \ + <(git diff --name-only "$base" "$target_oid" -- | sort) \ <(git diff --name-only "$base" HEAD -- | sort)) if [ -z "$overlap" ]; then @@ -26,9 +32,9 @@ if [ -z "$overlap" ]; then fi { - echo "Branch is behind origin/main, and main changed files this branch also touches:" + echo "Branch is behind origin/$target, which changed files this branch also touches:" echo "$overlap" | sed 's/^/ /' - echo "Local checks ran on a tree CI will never test. Run 'git merge origin/main'," + echo "Local checks ran on a tree CI will never test. Run 'git merge origin/$target'," echo "resolve, re-run checks, then push." } >&2 exit 1 diff --git a/scripts/test-branch-skew.sh b/scripts/test-branch-skew.sh new file mode 100755 index 00000000000..ffbf74bc7b8 --- /dev/null +++ b/scripts/test-branch-skew.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +guard="$repo_root/scripts/check-branch-skew.sh" +fixture_root=$(mktemp -d) +trap 'rm -rf "$fixture_root"' EXIT +upstream="$fixture_root/upstream" +client="$fixture_root/client" + +# Construct synthetic history without running contributor hooks on fixture commits. +commit_fixture() { + local repo="$1" file="$2" content="$3" tree oid + local args=(-m fixture) + printf '%s\n' "$content" >"$repo/$file" + git -C "$repo" add "$file" + tree=$(git -C "$repo" write-tree) + if git -C "$repo" rev-parse --verify HEAD >/dev/null 2>&1; then + args+=(-p HEAD) + fi + oid=$(GIT_AUTHOR_NAME=Fixture GIT_AUTHOR_EMAIL=fixture@example.com \ + GIT_COMMITTER_NAME=Fixture GIT_COMMITTER_EMAIL=fixture@example.com \ + git -C "$repo" commit-tree "$tree" "${args[@]}") + git -C "$repo" update-ref HEAD "$oid" +} + +expect_pass() { + (cd "$client" && "$guard") >"$fixture_root/result" 2>&1 || { + cat "$fixture_root/result" >&2 + echo "Expected pass: $1" >&2 + exit 1 + } +} + +expect_fail() { + if (cd "$client" && "$guard") >"$fixture_root/result" 2>&1; then + echo "Expected failure: $1" >&2 + exit 1 + fi +} + +git init -q -b main "$upstream" +commit_fixture "$upstream" shared base +git -C "$upstream" branch parent +git -C "$upstream" branch stable +git clone -q "$upstream" "$client" +git -C "$client" checkout -qb feature +commit_fixture "$client" shared feature +expect_pass 'ordinary branch based on current main' + +commit_fixture "$upstream" shared main-advanced +expect_fail 'ordinary branch overlaps newer main' +grep -q 'behind origin/main' "$fixture_root/result" + +git -C "$client" config branch.feature.gh-merge-base parent +expect_pass 'stacked branch uses parent despite overlapping main' +git -C "$upstream" checkout -q parent +commit_fixture "$upstream" other disjoint +expect_pass 'parent advances in a disjoint file' +commit_fixture "$upstream" shared parent-advanced +expect_fail 'stacked branch overlaps newer parent' +grep -q 'behind origin/parent' "$fixture_root/result" + +for invalid in '' 'bad base' feature; do + git -C "$client" config branch.feature.gh-merge-base "$invalid" + expect_fail "invalid base '$invalid'" + grep -q 'Invalid PR base' "$fixture_root/result" +done +git -C "$client" config branch.feature.gh-merge-base missing +expect_fail 'missing remote branch' + +# A cached tracking ref must not turn an unavailable upstream into success. +git -C "$client" config branch.feature.gh-merge-base stable +expect_pass 'cached base passes while upstream is reachable' +git -C "$client" config remote.origin.url "$fixture_root/unavailable" +expect_fail 'fetch failure with cached parent' + +echo 'Branch-skew regression checks passed'