{"record":{"id":"35e1274da343a7c3","repo":"zeroclaw-labs/zeroclaw","slug":"approval-route-channel-channel-key-does-not-su","errorCode":null,"errorMessage":"approval route channel '{channel_key}' does not support outbound delivery (it is inbound-only); its approval notice cannot be sent (route '{route}')","messagePattern":"approval route channel '(.+?)' does not support outbound delivery \\(it is inbound-only\\); its approval notice cannot be sent \\(route '(.+?)'\\)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/sop/approval/channel_route.rs","lineNumber":386,"sourceCode":"        kind: ApprovalNoticeKind,\n        route: &str,\n        notice: &GateNotice<'_>,\n    ) -> anyhow::Result<()> {\n        let (channel_key, msg) = build_delivery(kind, route, notice)?;\n        let Some(channel) = self.channels.get(&channel_key).cloned() else {\n            // A misconfigured route (names a channel that isn't configured) is a real\n            // operator error worth surfacing: return Err so the broker logs it. It\n            // still never affects the gate (the broker's deliver_* wrappers only log).\n            anyhow::bail!(\n                \"approval route channel '{channel_key}' is not a configured channel \\\n                 (route '{route}')\"\n            );\n        };\n        // An inbound-only channel's `send` is a no-op that returns `Ok`, so spawning it\n        // would report success without delivering anything. Refuse and surface it (the\n        // broker logs the Err) rather than silently dropping the notice.\n        if !channel.supports_outbound_send() {\n            anyhow::bail!(\n                \"approval route channel '{channel_key}' does not support outbound \\\n                 delivery (it is inbound-only); its approval notice cannot be sent \\\n                 (route '{route}')\"\n            );\n        }\n        // Fire-and-forget: hand the async send to the runtime and return. The gate is\n        // never blocked on channel I/O; a send failure is logged in the task.\n        // Native gate prompt first (buttons / keyboards, answered through the\n        // channel's inbound path); channels without one fall back to the text\n        // notice, whose `approve <run_id>` reply the orchestrator also resolves.\n        let prompt = build_gate_prompt(kind, notice);\n        let recipient = msg.recipient.clone();\n        let run_id = notice.run_id.to_string();\n        let route = route.to_string();\n        self.handle.spawn(async move {\n            let prompted = match channel.send_gate_prompt(&recipient, &prompt).await {\n                Ok(prompted) => prompted,\n                Err(e) => {","sourceCodeStart":368,"sourceCodeEnd":404,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/sop/approval/channel_route.rs#L368-L404","documentation":"The route's channel is configured, but it is inbound-only (channel.supports_outbound_send() is false). Its send() is a no-op returning Ok, so spawning it would report success while delivering nothing; deliver() refuses and surfaces the error so the notice is not silently dropped. The broker only logs this — the gate is unaffected.","triggerScenarios":"deliver() with a route whose channel half names an inbound-only channel (e.g. a webhook listener or feed-style channel) — any channel registered without outbound send support.","commonSituations":"Routing approvals to a webhook/ingest channel by mistake; channel type changed to inbound-only in an upgrade; reusing an existing inbound channel key for approvals instead of adding a two-way one; misreading which integrations can send messages.","solutions":["Point the approval route at a channel with outbound delivery (e.g. a discord/slack/telegram channel key).","If the integration supports sending, enable/configure its outbound side so supports_outbound_send() is true.","Keep a documented list of outbound-capable channel keys and validate routes against it in config review."],"exampleFix":"# before\n[channels.webhook-ingest]   # inbound-only\nurl = \"https://...\"\n[approval]\nroute = \"webhook-ingest:ops\"\n\n# after\n[channels.discord.ops]\ntoken = \"...\"\n[approval]\nroute = \"discord.ops:123456789\"","handlingStrategy":"validation","validationCode":"let (channel_key, _) = route.split_once(':').expect(\"validated route\");\nif let Some(ch) = channels.get(channel_key) {\n    assert!(ch.supports_outbound_send(), \"channel '{channel_key}' is inbound-only; pick an outbound channel for approvals\");\n}","typeGuard":"fn route_channel_can_send(route: &str, channels: &ChannelMap) -> bool {\n    route.split_once(':')\n        .and_then(|(c, _)| channels.get(c))\n        .map(|ch| ch.supports_outbound_send())\n        .unwrap_or(false)\n}","tryCatchPattern":"match router.deliver(kind, route, &notice).await {\n    Err(e) if e.to_string().contains(\"inbound-only\") => {\n        log::warn!(\"route {route} targets an inbound-only channel; repoint at an outbound-capable one\");\n    }\n    rest => rest?,\n}","preventionTips":["Maintain a documented list of outbound-capable channel keys and review approval routes against it.","Config lint: every approval route must resolve to a channel where supports_outbound_send() is true.","Remember the gate still functions when delivery fails — do not treat notice failures as approval failures."],"tags":["config","approval","routing","channel","inbound-only","delivery"],"backgroundTag":"channel-not-sendable","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}