zeroclaw-labs/zeroclaw · error · anyhow::Error

approval route '{route}' is not 'channel:recipient' (e.g. 'd

Error message

approval route '{route}' is not 'channel:recipient' (e.g. 'discord.ops:123456789') - both halves must be non-empty

What it means

Approval notices are routed by a config string 'channel:recipient'. build_delivery() parses it and requires both halves non-empty (e.g. 'discord.ops:123456789'); a route without a colon, or with an empty channel or recipient half, fails before any message is built.

Source

Thrown at crates/zeroclaw-runtime/src/sop/approval/channel_route.rs:356

            }
        },
        description,
        reference: gate_reference(notice),
        choices,
        resolved_description: Some(resolved_description),
    }
}

/// Build the (channel_key, message) delivery pair from a route + run identity, or an
/// error describing why it can't be built. PURE (no I/O, no spawn) so the parse +
/// message-shaping is unit-testable without a runtime.
fn build_delivery(
    kind: ApprovalNoticeKind,
    route: &str,
    notice: &GateNotice<'_>,
) -> anyhow::Result<(String, SendMessage)> {
    let Some((channel_key, recipient)) = parse_approval_route(route) else {
        anyhow::bail!(
            "approval route '{route}' is not 'channel:recipient' (e.g. \
             'discord.ops:123456789') - both halves must be non-empty"
        );
    };
    let msg = SendMessage::new(render_notice(kind, notice), recipient).suppress_voice();
    Ok((channel_key.to_string(), msg))
}

impl ApprovalRouteAdapter for ChannelRouteAdapter {
    fn deliver(
        &self,
        kind: ApprovalNoticeKind,
        route: &str,
        notice: &GateNotice<'_>,
    ) -> anyhow::Result<()> {
        let (channel_key, msg) = build_delivery(kind, route, notice)?;
        let Some(channel) = self.channels.get(&channel_key).cloned() else {
            // A misconfigured route (names a channel that isn't configured) is a real

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Set the route to 'channel:recipient' with both halves, e.g. approval_route = "discord.ops:123456789".
  2. Quote the value in YAML/ TOML so the colon survives parsing intact.
  3. Confirm the channel half exactly matches a configured channel key (see the next error) and the recipient is a real id for that channel.
  4. Add a startup config test that validates every approval route matches ^[^:\s]+:[^:\s]+$.

Example fix

# before
[approval]
route = "discord.ops"

# after
[approval]
route = "discord.ops:123456789"
Defensive patterns

Strategy: validation

Validate before calling

fn parse_route(route: &str) -> Option<(&str, &str)> {
    let (ch, recv) = route.split_once(':')?;
    let ok = |s: &str| !s.trim().is_empty() && !s.contains(':');
    (ok(ch) && ok(recv)).then_some((ch.trim(), recv.trim()))
}
assert!(parse_route(&config.approval.route).is_some(), "route must be 'channel:recipient'");

Type guard

fn is_valid_route(route: &str) -> bool {
    route.split_once(':')
        .map(|(c, r)| !c.is_empty() && !r.is_empty())
        .unwrap_or(false)
}

Try / catch

match approval_router.deliver(kind, route, &notice).await {
    Err(e) if e.to_string().contains("is not 'channel:recipient'") => {
        eprintln!("config error: approval route {route:?} must be 'channel:recipient'");
    }
    rest => rest?,
}

Prevention

When it happens

Trigger: A gate fires a notice with approval_route set to something like "discord.ops" (missing ':recipient'), " :123" or "discord.ops:" (empty half), or a bare recipient id. parse_approval_route returns None and build_delivery bails.

Common situations: Hand-edited config omitting the recipient after a colon; YAML quoting that drops or merges the colon; placeholder values never replaced; operators assuming the channel default recipient is implied.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/ed46b6caaf96b76e. Report an issue: GitHub.