From 5250b55056bbee5f184e08cb91525eb3ccaebfcd Mon Sep 17 00:00:00 2001
From: webdevtodayjason <jason@webdevtoday.com>
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 <jason@webdevtoday.com>
---
 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<MatchedRule> {
     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 <jason@webdevtoday.com>
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 <jason@webdevtoday.com>
---
 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<bool> {
+    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)]
