From 5250b55056bbee5f184e08cb91525eb3ccaebfcd Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Fri, 24 Jul 2026 17:33:30 -0500 Subject: [PATCH 1/2] fix(acp): answer owner DMs without an explicit @mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a 1:1 DM the agent required an @mention on every message: the mention gate (require_mention) was applied uniformly to every channel, DM included, at both the relay subscription filter (drops untagged events before delivery) and match_event (re-checks the p-tag). A DM is addressed to the counterparty by definition, so this made agents look unresponsive to their own owner. Exempt the gate for owner-authored DM events — computed as is_dm && author == owner and threaded to match_event as mention_exempt, with the DM channels' subscription filter dropping the #p requirement. Scoped to the owner so agent-to-agent DM messages still require a mention, preserving the #2270 anti-loop invariant. Group channels are unchanged. DM turns are already restricted to owner + verified siblings (#2591), so this does not expose agents to third-party prompting. Closes #2747. Signed-off-by: webdevtodayjason --- crates/buzz-acp/src/config.rs | 45 +++++++++++++++++ crates/buzz-acp/src/filter.rs | 83 +++++++++++++++++++++++++++---- crates/buzz-acp/src/lib.rs | 49 +++++++++++++----- crates/buzz-acp/src/setup_mode.rs | 5 +- 4 files changed, 158 insertions(+), 24 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..3d9a15230f 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -482,6 +482,24 @@ pub struct ChannelFilter { pub require_mention: bool, } +impl ChannelFilter { + /// Drop the relay-side `#p` mention filter when the channel is a DM. + /// + /// A 1:1 DM is addressed to the agent by definition, so the relay must + /// deliver every message in it — not just `p`-tagged ones — otherwise the + /// agent never sees plain (un-@mentioned) DM messages (issue #2747). This + /// only widens *delivery*; per-event triggering is still gated by the + /// inbound author gate and `match_event`, which restrict DM turns to the + /// owner. Group channels keep their mention filter unchanged. + #[must_use] + pub fn with_dm_exemption(mut self, is_dm: bool) -> Self { + if is_dm { + self.require_mention = false; + } + self + } +} + #[derive(Debug)] pub struct Config { pub keys: Keys, @@ -1330,6 +1348,33 @@ mod tests { use crate::filter::{ChannelScope, SubscriptionRule}; use clap::{Parser, ValueEnum}; + #[test] + fn test_with_dm_exemption_clears_mention_for_dm() { + let filter = ChannelFilter { + kinds: Some(vec![9]), + require_mention: true, + } + .with_dm_exemption(true); + assert!( + !filter.require_mention, + "DM must drop the relay-side #p mention filter" + ); + assert_eq!(filter.kinds, Some(vec![9]), "kinds must be preserved"); + } + + #[test] + fn test_with_dm_exemption_preserves_mention_for_non_dm() { + let filter = ChannelFilter { + kinds: Some(vec![9]), + require_mention: true, + } + .with_dm_exemption(false); + assert!( + filter.require_mention, + "group channels keep the mention filter unchanged" + ); + } + /// Build a minimal Config for testing without CLI parsing. fn test_config(mode: SubscribeMode) -> Config { Config { diff --git a/crates/buzz-acp/src/filter.rs b/crates/buzz-acp/src/filter.rs index 43edd969dd..ff0b589029 100644 --- a/crates/buzz-acp/src/filter.rs +++ b/crates/buzz-acp/src/filter.rs @@ -351,9 +351,19 @@ const MAX_CONSECUTIVE_TIMEOUTS: u32 = 5; /// 2. **kinds** — if non-empty, the event kind must be in the list. /// 3. **require_mention** — if `true`, a `p` tag matching `agent_pubkey_hex` must /// exist. Tag kind is checked via `tag.as_slice()` for stable, library-independent -/// access. +/// access. Bypassed when `mention_exempt` is set (see below). /// 4. **filter** — if `Some`, the evalexpr expression must evaluate to `true`. /// +/// # DM mention exemption (`mention_exempt`) +/// +/// A 1:1 DM is addressed to the agent by definition, so an owner message in a +/// DM should fire a turn without an explicit `@mention` (issue #2747). The +/// caller computes `mention_exempt` — true only for an **owner-authored** event +/// in a DM channel — and when set, step 3's mention check is skipped. It is +/// never set for sibling-agent or group-channel events, so the anti-loop +/// invariant (agents don't auto-reply to each other unmentioned) and the group +/// mention gate are both preserved. `kinds` and `filter` gating still apply. +/// /// # Fail-closed filter error handling /// /// Any filter evaluation error — including timeout — causes the **entire @@ -370,6 +380,7 @@ pub async fn match_event( channel_id: uuid::Uuid, rules: &[SubscriptionRule], agent_pubkey_hex: &str, + mention_exempt: bool, ) -> Option { let filter_ctx = FilterContext::from_event(event, channel_id); @@ -387,7 +398,8 @@ pub async fn match_event( // 3. Mention check — look for a `p` tag whose first element equals // agent_pubkey_hex. Uses tag.as_slice() for stable, library-independent // access — avoids relying on the Display impl of tag kind. - if rule.require_mention { + // Skipped when `mention_exempt` is set (owner message in a DM, #2747). + if rule.require_mention && !mention_exempt { let mentioned = event.tags.iter().any(|tag| { let s = tag.as_slice(); s.first().map(|k| k.as_str()) == Some("p") @@ -601,7 +613,9 @@ mod tests { ), ]; - let matched = match_event(&event, channel_id, &rules, "").await.unwrap(); + let matched = match_event(&event, channel_id, &rules, "", false) + .await + .unwrap(); assert_eq!(matched.rule_index, 0); assert_eq!(matched.prompt_tag, "tag-first"); } @@ -630,7 +644,9 @@ mod tests { ), ]; - let matched = match_event(&event, channel_id, &rules, "").await.unwrap(); + let matched = match_event(&event, channel_id, &rules, "", false) + .await + .unwrap(); assert_eq!(matched.rule_index, 1); assert_eq!(matched.prompt_tag, "matched"); } @@ -653,16 +669,61 @@ mod tests { )]; // Without mention — no match. - let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey).await; + let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey, false).await; assert!(result.is_none()); // With mention — matches. - let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey) + let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey, false) + .await + .unwrap(); + assert_eq!(matched.prompt_tag, "mentioned"); + } + + #[tokio::test] + async fn test_match_event_mention_exempt_bypasses_require_mention() { + // mention_exempt=true (owner message in a DM, #2747): a require_mention + // rule matches even without a `p` tag. Group/sibling paths never set the + // flag, so this does not weaken the mention gate elsewhere. + let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let event_no_mention = make_event(9, "hello"); + let channel_id = any_channel(); + + let rules = vec![make_rule( + "mention-only", + ChannelScope::All("all".into()), + vec![], + true, + None, + Some("mentioned"), + )]; + + let matched = match_event(&event_no_mention, channel_id, &rules, agent_pubkey, true) .await .unwrap(); assert_eq!(matched.prompt_tag, "mentioned"); } + #[tokio::test] + async fn test_match_event_mention_exempt_still_honors_kind_filter() { + // The exemption only bypasses the mention check — kind gating still + // applies, so an owner DM of the wrong kind is not matched. + let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let event_kind_1 = make_event(1, "hello"); + let channel_id = any_channel(); + + let rules = vec![make_rule( + "kind-9-mention", + ChannelScope::All("all".into()), + vec![9], + true, + None, + Some("mentioned"), + )]; + + let result = match_event(&event_kind_1, channel_id, &rules, agent_pubkey, true).await; + assert!(result.is_none()); + } + #[tokio::test] async fn test_match_event_no_match() { let event = make_event(1, "hello"); @@ -677,7 +738,7 @@ mod tests { None, )]; - let result = match_event(&event, channel_id, &rules, "").await; + let result = match_event(&event, channel_id, &rules, "", false).await; assert!(result.is_none()); } @@ -725,7 +786,9 @@ mod tests { None, // no explicit tag )]; - let matched = match_event(&event, channel_id, &rules, "").await.unwrap(); + let matched = match_event(&event, channel_id, &rules, "", false) + .await + .unwrap(); assert_eq!(matched.prompt_tag, "my-rule"); } @@ -755,7 +818,7 @@ mod tests { ]; // Must return None — not "catch-all". - let result = match_event(&event, channel_id, &rules, "").await; + let result = match_event(&event, channel_id, &rules, "", false).await; assert!( result.is_none(), "filter error must fail closed, not fall through to next rule" @@ -781,7 +844,7 @@ mod tests { .store(MAX_CONSECUTIVE_TIMEOUTS, Ordering::Relaxed); let rules = vec![rule]; - let result = match_event(&event, channel_id, &rules, "").await; + let result = match_event(&event, channel_id, &rules, "", false).await; assert!(result.is_none(), "disabled rule must return None"); } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0230ea0875..52f2a7ff83 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1479,7 +1479,16 @@ async fn tokio_main() -> Result<()> { } let mut subscribed_channel_ids = HashSet::with_capacity(channel_filters.len()); for (channel_id, filter) in &channel_filters { - if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + // DM mention exemption (#2747): drop the relay-side `#p` filter for + // channels known to be DMs at startup so plain DM messages are + // delivered. Unknown types keep the mention filter (fail narrow at the + // subscription layer — per-event gating re-resolves the type later). + let is_dm = channel_info_map + .get(channel_id) + .map(|ci| ci.channel_type == "dm") + .unwrap_or(false); + let filter = filter.clone().with_dm_exemption(is_dm); + if let Err(e) = relay.subscribe_channel(*channel_id, filter).await { tracing::warn!("failed to subscribe to channel {channel_id}: {e}"); } else { subscribed_channel_ids.insert(*channel_id); @@ -1969,6 +1978,14 @@ async fn tokio_main() -> Result<()> { tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); + // DM mention exemption (#2747): a DM the + // agent is added to post-startup (e.g. an + // agent-initiated DM) must deliver plain + // messages too. Resolve type via the shared + // resolver (fail-closed to DM on unknown, + // matching the inbound author gate). + let is_dm = is_dm_channel(ch, &ctx.channel_info).await; + let filter = filter.with_dm_exemption(is_dm); if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); } else { @@ -2143,13 +2160,13 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. + let author = buzz_event.event.pubkey.to_hex(); + // DM hardening: resolve channel type (fail-closed + // to DM) so allowlist/anyone modes cannot be + // exercised by non-owner authors inside DMs. + let is_dm = + is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; { - let author = buzz_event.event.pubkey.to_hex(); - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be - // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, @@ -2162,7 +2179,7 @@ async fn tokio_main() -> Result<()> { if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), + author = %author, mode = %config.respond_to, is_dm, "inbound author gate — dropping event" @@ -2171,7 +2188,14 @@ async fn tokio_main() -> Result<()> { } } - let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; + // DM mention exemption (#2747): a 1:1 DM is addressed + // to the agent by definition, so an owner message in a + // DM fires a turn without an explicit @mention. Scoped + // to the owner (not siblings) so sibling agents still + // need a mention — preserving the anti-loop invariant + // (#2270). Group channels are never exempt. + let mention_exempt = is_dm && owner_cache.get() == Some(author.as_str()); + let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex, mention_exempt).await; let prompt_tag = match matched { Some(m) => m.prompt_tag, None => { @@ -2179,9 +2203,8 @@ async fn tokio_main() -> Result<()> { continue; } }; - // Capture author pubkey before queue.push() moves - // buzz_event.event (needed for mode gate below). - let author_hex = buzz_event.event.pubkey.to_hex(); + // `author` (captured above, before queue.push() moves + // buzz_event.event) is reused by the mode gate below. let event_id_hex = buzz_event.event.id.to_hex(); // Clone for the non-cancelling steer fork, which // needs the event to render the steer body. The @@ -2223,7 +2246,7 @@ async fn tokio_main() -> Result<()> { // event that reaches here. let signal = mode_gate_signal( config.multiple_event_handling, - &author_hex, + &author, owner_cache.get(), ); if let Some(signal) = signal { diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..618e6a90c7 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -440,12 +440,15 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> ) .await; - // Apply channel/kind filter rules. + // Apply channel/kind filter rules. Setup mode keeps its own explicit + // @mention requirement (above) regardless of channel type, so it opts + // out of the DM mention exemption (#2747) with mention_exempt = false. let filter_matched = filter::match_event( &buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex, + false, ) .await .is_some(); From b4292e6e1db67ae4fd1d7b005b52ae973a955d0c Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Sun, 26 Jul 2026 16:22:24 -0500 Subject: [PATCH 2/2] fix(acp): require positive DM metadata for mention-filter bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamic-channel path reused the fail-closed `is_dm_channel` verdict for two unrelated decisions. `is_dm_channel` deliberately returns true when metadata resolution fails (fail-closed for the author authorization gate), but that same value was reused to (a) drop the relay `#p` filter when subscribing to a newly-joined channel and (b) exempt an owner event from `require_mention`. A transient metadata failure on a newly-joined NON-DM channel therefore made that channel behave like a DM for mention matching — contradicting the startup path's "unknown types keep the mention filter" behavior. Split the two verdicts: - `is_dm_channel` (fail-closed, unknown => DM) is kept unchanged for the author authorization gate, so an unresolved channel still restricts to the owner. - `is_dm_channel_confirmed` (fail-open, requires a positive `channel_type == "dm"`) now gates the mention-filter bypass at both sites — the membership- notification `#p`/relay-filter removal and the `require_mention` exemption. Unknown/unresolved types keep the relay `#p` filter AND `require_mention`. Both verdicts derive from a single shared `resolve_dm_verdict` resolution, so the per-event path still resolves channel metadata only once. This mirrors the startup subscription path, which already required a positive DM type. Adds regression tests proving an unresolved/unknown channel type retains `require_mention` (no mention-filter bypass) while still failing closed at the author gate, plus a test that a positively-resolved DM still bypasses. Addresses review on #2777. Signed-off-by: webdevtodayjason --- crates/buzz-acp/src/lib.rs | 192 +++++++++++++++++++++++++++++++++---- 1 file changed, 173 insertions(+), 19 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 52f2a7ff83..a68a01f606 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -257,7 +257,7 @@ async fn author_allowed( } } -/// Resolve whether `channel_id` is a DM, for the inbound author gate. +/// Resolve a channel's DM verdict from metadata. /// /// Resolution order: /// 1. Startup discovery metadata (`startup_info`) — covers channels known at @@ -267,15 +267,40 @@ async fn author_allowed( /// the agent was added to *after* startup (the exploit path: an /// agent-initiated DM is exactly such a channel). /// -/// Fail-closed: if the fetch fails or times out, the channel is treated as a -/// DM for this event and the result is NOT cached, so a later event retries +/// Returns `Some(true)` when the channel positively resolves to a DM, +/// `Some(false)` when it resolves to any other type, and `None` when the +/// metadata is unresolved (fetch failed/timed out, or startup type is +/// `"unknown"`). The unresolved result is NOT cached, so a later event retries /// the fetch instead of pinning a mis-classification. +/// +/// Callers pick the failure semantics that match their gate: +/// - [`is_dm_channel`] fails **closed** (unresolved ⇒ DM) for the inbound +/// author gate, so a transient failure can only *restrict* authorization. +/// - [`is_dm_channel_confirmed`] fails **open** (unresolved ⇒ not DM) for the +/// mention-filter bypass, so a transient failure never *relaxes* the mention +/// requirement on a non-DM channel. +pub(crate) async fn resolve_dm_verdict( + channel_id: Uuid, + channel_info: &pool::ChannelInfoResolver, +) -> Option { + channel_info + .resolve(channel_id) + .await + .map(|info| info.channel_type == "dm") +} + +/// Resolve whether `channel_id` is a DM, for the inbound author gate. +/// +/// Fail-closed: if the type is unresolved, the channel is treated as a DM for +/// this event so allowlist/anyone modes cannot be exercised by non-owner +/// authors inside an unclassified channel. A transient failure can only ever +/// *restrict* authorization, never widen it. See [`resolve_dm_verdict`]. pub(crate) async fn is_dm_channel( channel_id: Uuid, channel_info: &pool::ChannelInfoResolver, ) -> bool { - match channel_info.resolve(channel_id).await { - Some(info) => info.channel_type == "dm", + match resolve_dm_verdict(channel_id, channel_info).await { + Some(is_dm) => is_dm, None => { tracing::warn!( channel_id = %channel_id, @@ -286,6 +311,24 @@ pub(crate) async fn is_dm_channel( } } +/// Resolve whether `channel_id` is *positively confirmed* as a DM, for the +/// mention-filter bypass (dropping the relay `#p` filter and exempting owner +/// messages from `require_mention`). +/// +/// Fail-**open**: only metadata that positively resolves to `channel_type == +/// "dm"` bypasses the mention requirement. Unknown or unresolved channel types +/// return `false`, so a transient metadata failure on a non-DM channel keeps +/// its mention filter instead of making it behave like a DM — mirroring the +/// startup path's "unknown types keep the mention filter" rule. Contrast with +/// [`is_dm_channel`], which fails closed for the author gate. See +/// [`resolve_dm_verdict`]. +pub(crate) async fn is_dm_channel_confirmed( + channel_id: Uuid, + channel_info: &pool::ChannelInfoResolver, +) -> bool { + resolve_dm_verdict(channel_id, channel_info).await == Some(true) +} + /// Query an author's kind:0 profile and check if their NIP-OA auth tag /// proves the same owner as us. async fn check_sibling_via_profile( @@ -1981,11 +2024,13 @@ async fn tokio_main() -> Result<()> { // DM mention exemption (#2747): a DM the // agent is added to post-startup (e.g. an // agent-initiated DM) must deliver plain - // messages too. Resolve type via the shared - // resolver (fail-closed to DM on unknown, - // matching the inbound author gate). - let is_dm = is_dm_channel(ch, &ctx.channel_info).await; - let filter = filter.with_dm_exemption(is_dm); + // messages too. Drop the relay `#p` filter + // ONLY on a positively-confirmed DM — an + // unresolved/unknown type keeps the mention + // filter, matching the startup path (#2777). + let is_dm_confirmed = + is_dm_channel_confirmed(ch, &ctx.channel_info).await; + let filter = filter.with_dm_exemption(is_dm_confirmed); if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); } else { @@ -2161,17 +2206,34 @@ async fn tokio_main() -> Result<()> { // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. let author = buzz_event.event.pubkey.to_hex(); - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be - // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; + // DM hardening: resolve the channel's DM verdict ONCE, + // then split it into two gates with opposite failure + // semantics (#2777): + // • author gate — fails CLOSED (unresolved ⇒ DM) so + // allowlist/anyone modes cannot be exercised by + // non-owner authors inside an unclassified channel; + // • mention bypass — fails OPEN (unresolved ⇒ not DM) + // so a transient metadata failure never strips the + // mention requirement from a non-DM channel. + let dm_verdict = + resolve_dm_verdict(buzz_event.channel_id, &ctx.channel_info).await; + let is_dm_for_auth = match dm_verdict { + Some(is_dm) => is_dm, + None => { + tracing::warn!( + channel_id = %buzz_event.channel_id, + "channel type unresolved — treating as DM for author gate (fail closed)" + ); + true + } + }; + let is_dm_confirmed = dm_verdict == Some(true); { let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, &author, - is_dm, + is_dm_for_auth, &owner_cache, &ctx.rest_client, ) @@ -2181,7 +2243,7 @@ async fn tokio_main() -> Result<()> { channel_id = %buzz_event.channel_id, author = %author, mode = %config.respond_to, - is_dm, + is_dm = is_dm_for_auth, "inbound author gate — dropping event" ); continue; @@ -2193,8 +2255,10 @@ async fn tokio_main() -> Result<()> { // DM fires a turn without an explicit @mention. Scoped // to the owner (not siblings) so sibling agents still // need a mention — preserving the anti-loop invariant - // (#2270). Group channels are never exempt. - let mention_exempt = is_dm && owner_cache.get() == Some(author.as_str()); + // (#2270). Requires a POSITIVELY-confirmed DM: group and + // unresolved channels are never exempt (#2777). + let mention_exempt = + is_dm_confirmed && owner_cache.get() == Some(author.as_str()); let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex, mention_exempt).await; let prompt_tag = match matched { Some(m) => m.prompt_tag, @@ -4759,6 +4823,96 @@ mod author_gate_tests { "an unresolvable channel type must be treated as a DM" ); } + + // ── is_dm_channel_confirmed: the mention-filter bypass predicate (#2777) ── + // + // Unlike `is_dm_channel` (fail-CLOSED for the author gate), the bypass + // predicate fails OPEN: only a POSITIVELY-resolved `channel_type == "dm"` + // drops the relay `#p` filter / exempts the owner from `require_mention`. + // Unknown or unresolved types must keep the mention filter, matching the + // startup subscription path. + + #[tokio::test] + async fn test_is_dm_channel_confirmed_positive_for_declared_dm() { + let dm_id = Uuid::new_v4(); + let stream_id = Uuid::new_v4(); + let startup = HashMap::from([ + ( + dm_id, + relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }, + ), + ( + stream_id, + relay::ChannelInfo { + name: "stream".into(), + channel_type: "stream".into(), + }, + ), + ]); + let resolver = resolver(startup); + assert!( + is_dm_channel_confirmed(dm_id, &resolver).await, + "a positively-resolved DM must bypass the mention filter" + ); + assert!( + !is_dm_channel_confirmed(stream_id, &resolver).await, + "a resolved non-DM channel must keep the mention filter" + ); + } + + #[tokio::test] + async fn test_unresolved_channel_type_splits_the_two_verdicts() { + // The core #2777 regression: a channel whose type cannot be resolved + // must fail CLOSED for the author gate but fail OPEN for the mention + // bypass. A transient metadata failure on a newly-joined non-DM channel + // must NOT make it behave like a DM for mention matching. + let id = Uuid::new_v4(); + // Empty startup + unreachable relay ⇒ the type never resolves. + let resolver = resolver(HashMap::new()); + + assert_eq!( + resolve_dm_verdict(id, &resolver).await, + None, + "an unresolvable channel type must produce no verdict" + ); + assert!( + is_dm_channel(id, &resolver).await, + "author gate fails CLOSED: unresolved ⇒ treated as DM (owner-only)" + ); + assert!( + !is_dm_channel_confirmed(id, &resolver).await, + "mention bypass fails OPEN: unresolved ⇒ NOT a DM, require_mention retained" + ); + } + + #[tokio::test] + async fn test_unknown_startup_type_retains_mention_filter() { + // A channel present at startup with an "unknown" type (the reviewer's + // newly-joined-channel case) must keep its mention filter while still + // failing closed at the author gate — matching the startup path's + // "unknown types keep the mention filter" behavior. + let id = Uuid::new_v4(); + let startup = HashMap::from([( + id, + relay::ChannelInfo { + name: "unknown".into(), + channel_type: "unknown".into(), + }, + )]); + let resolver = resolver(startup); + + assert!( + is_dm_channel(id, &resolver).await, + "unknown startup type must fail closed as DM at the author gate" + ); + assert!( + !is_dm_channel_confirmed(id, &resolver).await, + "unknown startup type must NOT be confirmed as DM — mention filter retained" + ); + } } #[cfg(test)]