yah-tower 0.8.20

yah-tower — rule-driven monitor on scryer. Rule parser/compiler, ScryerFilter, and dispatch engine.
Documentation
//! Semantic validation of `TowerRule` values.
//!
//! Called after parsing, before loading a rule into the supervisor. Shape
//! errors (e.g. `Not` with 2 children) are caught at compile time; semantic
//! errors (e.g. empty rule ID, blank agent prompt) are caught here.

use tower_rules::{CompoundOp, FederationPolicy, Predicate, RuleId, Trigger, TowerRule};

/// Hard constraint violation that makes a rule impossible to load.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum RuleError {
    #[error("field {path}: {reason}")]
    Field { path: String, reason: String },
}

impl RuleError {
    fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
        RuleError::Field { path: path.into(), reason: reason.into() }
    }
}

/// Soft check that passed but may indicate misconfiguration.
#[derive(Debug, Clone, PartialEq)]
pub struct RuleWarning {
    pub path: String,
    pub message: String,
}

impl RuleWarning {
    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
        RuleWarning { path: path.into(), message: message.into() }
    }
}

/// Validate a parsed `TowerRule`.
///
/// Returns `Ok(warnings)` if the rule is semantically valid (warnings are
/// non-fatal and surfaced in the authoring UI). Returns `Err(RuleError)` on
/// the first hard constraint violation — the rule must not be loaded until the
/// error is fixed.
///
/// Call `compile` (compiler.rs) after this to also catch predicate structure
/// errors and regex syntax.
pub fn validate(rule: &TowerRule) -> Result<Vec<RuleWarning>, RuleError> {
    let mut warnings = Vec::new();

    validate_id(&rule.id)?;
    validate_predicate(&rule.predicate, "predicate")?;
    validate_trigger(&rule.trigger, &mut warnings)?;
    validate_federation_vs_predicate(rule, &mut warnings);

    Ok(warnings)
}

fn validate_id(id: &RuleId) -> Result<(), RuleError> {
    let s = &id.0;
    if s.is_empty() {
        return Err(RuleError::field("id", "rule ID must not be empty"));
    }
    // Kebab-case: lowercase alphanumeric and hyphens, no leading/trailing hyphen
    if !s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
        return Err(RuleError::field(
            "id",
            "rule ID must be kebab-case (lowercase alphanumeric and hyphens only)",
        ));
    }
    if s.starts_with('-') || s.ends_with('-') {
        return Err(RuleError::field(
            "id",
            "rule ID must not start or end with a hyphen",
        ));
    }
    Ok(())
}

fn validate_predicate(pred: &Predicate, path: &str) -> Result<(), RuleError> {
    match pred {
        Predicate::EventMatch { fields, .. } => {
            for (i, ff) in fields.iter().enumerate() {
                if ff.path.is_empty() {
                    return Err(RuleError::field(
                        format!("{path}.fields[{i}].path"),
                        "field path must not be empty",
                    ));
                }
            }
        }
        Predicate::ScryerHealth { .. } => {}
        Predicate::Compound { op, children } => {
            if children.is_empty() {
                return Err(RuleError::field(
                    format!("{path}.children"),
                    "Compound predicate must have at least one child",
                ));
            }
            if *op == CompoundOp::Not && children.len() != 1 {
                return Err(RuleError::field(
                    format!("{path}.children"),
                    format!("Not requires exactly 1 child, got {}", children.len()),
                ));
            }
            for (i, child) in children.iter().enumerate() {
                validate_predicate(child, &format!("{path}.children[{i}]"))?;
            }
        }
    }
    Ok(())
}

fn validate_trigger(trigger: &Trigger, warnings: &mut Vec<RuleWarning>) -> Result<(), RuleError> {
    match trigger {
        Trigger::AgentDispatch { agent_class, prompt_template, .. } => {
            if agent_class.0.is_empty() {
                return Err(RuleError::field(
                    "trigger.agent_class",
                    "agent class ref must not be empty",
                ));
            }
            if prompt_template.0.is_empty() {
                return Err(RuleError::field(
                    "trigger.prompt_template",
                    "prompt template must not be empty",
                ));
            }
            // Warn if the prompt template doesn't reference the event payload —
            // it's technically valid but probably a mistake.
            if !prompt_template.0.contains("{{event}}") && !prompt_template.0.contains("{{events}}") {
                warnings.push(RuleWarning::new(
                    "trigger.prompt_template",
                    "prompt template does not reference {{event}} or {{events}}; agent will run without event context",
                ));
            }
        }
        Trigger::Notification { channels, .. } => {
            if channels.is_empty() {
                return Err(RuleError::field(
                    "trigger.channels",
                    "at least one notification channel must be specified",
                ));
            }
        }
        Trigger::YubabaAction { target, .. } => {
            if target.0.is_empty() {
                return Err(RuleError::field(
                    "trigger.target",
                    "yubaba action target must not be empty",
                ));
            }
        }
    }
    Ok(())
}

fn validate_federation_vs_predicate(rule: &TowerRule, warnings: &mut Vec<RuleWarning>) {
    // A mesh-wide or mirror-set rule that only watches local health signals is
    // likely misconfigured — health signals are per-machine.
    if !matches!(rule.federation, FederationPolicy::LocalOnly) {
        if matches!(rule.predicate, Predicate::ScryerHealth { .. }) {
            warnings.push(RuleWarning::new(
                "federation",
                "ScryerHealth predicates watch per-machine scryer; federation is unlikely to be useful here",
            ));
        }
    }
}

#[cfg(test)]
mod tests {
    use tower_rules::*;
    use super::*;

    fn minimal_rule(predicate: Predicate) -> TowerRule {
        TowerRule {
            schema_version: SchemaVersion::V1,
            id: RuleId("test-rule".into()),
            name: "test".into(),
            predicate,
            trigger: Trigger::Notification {
                channels: vec![NotificationChannel::DesktopBadge],
                severity: Severity::Info,
            },
            debounce_ms: None,
            federation: FederationPolicy::LocalOnly,
            enabled: true,
        }
    }

    #[test]
    fn valid_rule_no_warnings() {
        let rule = minimal_rule(Predicate::ScryerHealth {
            signal: HealthSignal::YubabaRaftQuorumLost,
        });
        let warnings = validate(&rule).unwrap();
        assert!(warnings.is_empty());
    }

    #[test]
    fn empty_id_is_error() {
        let mut rule = minimal_rule(Predicate::ScryerHealth {
            signal: HealthSignal::IngestionLag,
        });
        rule.id = RuleId("".into());
        let err = validate(&rule).unwrap_err();
        assert!(err.to_string().contains("must not be empty"));
    }

    #[test]
    fn uppercase_id_is_error() {
        let mut rule = minimal_rule(Predicate::ScryerHealth {
            signal: HealthSignal::IngestionLag,
        });
        rule.id = RuleId("MyRule".into());
        let err = validate(&rule).unwrap_err();
        assert!(err.to_string().contains("kebab-case"));
    }

    #[test]
    fn leading_hyphen_id_is_error() {
        let mut rule = minimal_rule(Predicate::ScryerHealth {
            signal: HealthSignal::IngestionLag,
        });
        rule.id = RuleId("-rule".into());
        let err = validate(&rule).unwrap_err();
        assert!(err.to_string().contains("must not start or end with a hyphen"));
    }

    #[test]
    fn empty_notification_channels_is_error() {
        let rule = TowerRule {
            schema_version: SchemaVersion::V1,
            id: RuleId("test-rule".into()),
            name: "test".into(),
            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
            trigger: Trigger::Notification {
                channels: vec![],
                severity: Severity::Info,
            },
            debounce_ms: None,
            federation: FederationPolicy::LocalOnly,
            enabled: true,
        };
        let err = validate(&rule).unwrap_err();
        assert!(err.to_string().contains("at least one notification channel"));
    }

    #[test]
    fn empty_agent_class_is_error() {
        let rule = TowerRule {
            schema_version: SchemaVersion::V1,
            id: RuleId("dispatch-rule".into()),
            name: "test".into(),
            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
            trigger: Trigger::AgentDispatch {
                agent_class: AgentClassRef("".into()),
                prompt_template: PromptTemplate("fix: {{event}}".into()),
                placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
            },
            debounce_ms: None,
            federation: FederationPolicy::LocalOnly,
            enabled: true,
        };
        let err = validate(&rule).unwrap_err();
        assert!(err.to_string().contains("agent class ref must not be empty"));
    }

    #[test]
    fn agent_dispatch_without_event_ref_warns() {
        let rule = TowerRule {
            schema_version: SchemaVersion::V1,
            id: RuleId("dispatch-rule".into()),
            name: "test".into(),
            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
            trigger: Trigger::AgentDispatch {
                agent_class: AgentClassRef("gnome/fixer".into()),
                prompt_template: PromptTemplate("Please fix things.".into()),
                placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
            },
            debounce_ms: None,
            federation: FederationPolicy::LocalOnly,
            enabled: true,
        };
        let warnings = validate(&rule).unwrap();
        assert!(warnings.iter().any(|w| w.path == "trigger.prompt_template"));
    }

    #[test]
    fn scryer_health_with_mesh_wide_federation_warns() {
        let rule = TowerRule {
            schema_version: SchemaVersion::V1,
            id: RuleId("health-mesh".into()),
            name: "test".into(),
            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
            trigger: Trigger::Notification {
                channels: vec![NotificationChannel::DesktopBadge],
                severity: Severity::Warning,
            },
            debounce_ms: None,
            federation: FederationPolicy::MeshWide,
            enabled: true,
        };
        let warnings = validate(&rule).unwrap();
        assert!(warnings.iter().any(|w| w.path == "federation"));
    }
}