Skip to main content

tower/rules/
validate.rs

1//! Semantic validation of `TowerRule` values.
2//!
3//! Called after parsing, before loading a rule into the supervisor. Shape
4//! errors (e.g. `Not` with 2 children) are caught at compile time; semantic
5//! errors (e.g. empty rule ID, blank agent prompt) are caught here.
6
7use tower_rules::{CompoundOp, FederationPolicy, Predicate, RuleId, Trigger, TowerRule};
8
9/// Hard constraint violation that makes a rule impossible to load.
10#[derive(Debug, Clone, PartialEq, thiserror::Error)]
11pub enum RuleError {
12    #[error("field {path}: {reason}")]
13    Field { path: String, reason: String },
14}
15
16impl RuleError {
17    fn field(path: impl Into<String>, reason: impl Into<String>) -> Self {
18        RuleError::Field { path: path.into(), reason: reason.into() }
19    }
20}
21
22/// Soft check that passed but may indicate misconfiguration.
23#[derive(Debug, Clone, PartialEq)]
24pub struct RuleWarning {
25    pub path: String,
26    pub message: String,
27}
28
29impl RuleWarning {
30    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
31        RuleWarning { path: path.into(), message: message.into() }
32    }
33}
34
35/// Validate a parsed `TowerRule`.
36///
37/// Returns `Ok(warnings)` if the rule is semantically valid (warnings are
38/// non-fatal and surfaced in the authoring UI). Returns `Err(RuleError)` on
39/// the first hard constraint violation — the rule must not be loaded until the
40/// error is fixed.
41///
42/// Call `compile` (compiler.rs) after this to also catch predicate structure
43/// errors and regex syntax.
44pub fn validate(rule: &TowerRule) -> Result<Vec<RuleWarning>, RuleError> {
45    let mut warnings = Vec::new();
46
47    validate_id(&rule.id)?;
48    validate_predicate(&rule.predicate, "predicate")?;
49    validate_trigger(&rule.trigger, &mut warnings)?;
50    validate_federation_vs_predicate(rule, &mut warnings);
51
52    Ok(warnings)
53}
54
55fn validate_id(id: &RuleId) -> Result<(), RuleError> {
56    let s = &id.0;
57    if s.is_empty() {
58        return Err(RuleError::field("id", "rule ID must not be empty"));
59    }
60    // Kebab-case: lowercase alphanumeric and hyphens, no leading/trailing hyphen
61    if !s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
62        return Err(RuleError::field(
63            "id",
64            "rule ID must be kebab-case (lowercase alphanumeric and hyphens only)",
65        ));
66    }
67    if s.starts_with('-') || s.ends_with('-') {
68        return Err(RuleError::field(
69            "id",
70            "rule ID must not start or end with a hyphen",
71        ));
72    }
73    Ok(())
74}
75
76fn validate_predicate(pred: &Predicate, path: &str) -> Result<(), RuleError> {
77    match pred {
78        Predicate::EventMatch { fields, .. } => {
79            for (i, ff) in fields.iter().enumerate() {
80                if ff.path.is_empty() {
81                    return Err(RuleError::field(
82                        format!("{path}.fields[{i}].path"),
83                        "field path must not be empty",
84                    ));
85                }
86            }
87        }
88        Predicate::ScryerHealth { .. } => {}
89        Predicate::Compound { op, children } => {
90            if children.is_empty() {
91                return Err(RuleError::field(
92                    format!("{path}.children"),
93                    "Compound predicate must have at least one child",
94                ));
95            }
96            if *op == CompoundOp::Not && children.len() != 1 {
97                return Err(RuleError::field(
98                    format!("{path}.children"),
99                    format!("Not requires exactly 1 child, got {}", children.len()),
100                ));
101            }
102            for (i, child) in children.iter().enumerate() {
103                validate_predicate(child, &format!("{path}.children[{i}]"))?;
104            }
105        }
106    }
107    Ok(())
108}
109
110fn validate_trigger(trigger: &Trigger, warnings: &mut Vec<RuleWarning>) -> Result<(), RuleError> {
111    match trigger {
112        Trigger::AgentDispatch { agent_class, prompt_template, .. } => {
113            if agent_class.0.is_empty() {
114                return Err(RuleError::field(
115                    "trigger.agent_class",
116                    "agent class ref must not be empty",
117                ));
118            }
119            if prompt_template.0.is_empty() {
120                return Err(RuleError::field(
121                    "trigger.prompt_template",
122                    "prompt template must not be empty",
123                ));
124            }
125            // Warn if the prompt template doesn't reference the event payload —
126            // it's technically valid but probably a mistake.
127            if !prompt_template.0.contains("{{event}}") && !prompt_template.0.contains("{{events}}") {
128                warnings.push(RuleWarning::new(
129                    "trigger.prompt_template",
130                    "prompt template does not reference {{event}} or {{events}}; agent will run without event context",
131                ));
132            }
133        }
134        Trigger::Notification { channels, .. } => {
135            if channels.is_empty() {
136                return Err(RuleError::field(
137                    "trigger.channels",
138                    "at least one notification channel must be specified",
139                ));
140            }
141        }
142        Trigger::YubabaAction { target, .. } => {
143            if target.0.is_empty() {
144                return Err(RuleError::field(
145                    "trigger.target",
146                    "yubaba action target must not be empty",
147                ));
148            }
149        }
150    }
151    Ok(())
152}
153
154fn validate_federation_vs_predicate(rule: &TowerRule, warnings: &mut Vec<RuleWarning>) {
155    // A mesh-wide or mirror-set rule that only watches local health signals is
156    // likely misconfigured — health signals are per-machine.
157    if !matches!(rule.federation, FederationPolicy::LocalOnly) {
158        if matches!(rule.predicate, Predicate::ScryerHealth { .. }) {
159            warnings.push(RuleWarning::new(
160                "federation",
161                "ScryerHealth predicates watch per-machine scryer; federation is unlikely to be useful here",
162            ));
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use tower_rules::*;
170    use super::*;
171
172    fn minimal_rule(predicate: Predicate) -> TowerRule {
173        TowerRule {
174            schema_version: SchemaVersion::V1,
175            id: RuleId("test-rule".into()),
176            name: "test".into(),
177            predicate,
178            trigger: Trigger::Notification {
179                channels: vec![NotificationChannel::DesktopBadge],
180                severity: Severity::Info,
181            },
182            debounce_ms: None,
183            federation: FederationPolicy::LocalOnly,
184            enabled: true,
185        }
186    }
187
188    #[test]
189    fn valid_rule_no_warnings() {
190        let rule = minimal_rule(Predicate::ScryerHealth {
191            signal: HealthSignal::YubabaRaftQuorumLost,
192        });
193        let warnings = validate(&rule).unwrap();
194        assert!(warnings.is_empty());
195    }
196
197    #[test]
198    fn empty_id_is_error() {
199        let mut rule = minimal_rule(Predicate::ScryerHealth {
200            signal: HealthSignal::IngestionLag,
201        });
202        rule.id = RuleId("".into());
203        let err = validate(&rule).unwrap_err();
204        assert!(err.to_string().contains("must not be empty"));
205    }
206
207    #[test]
208    fn uppercase_id_is_error() {
209        let mut rule = minimal_rule(Predicate::ScryerHealth {
210            signal: HealthSignal::IngestionLag,
211        });
212        rule.id = RuleId("MyRule".into());
213        let err = validate(&rule).unwrap_err();
214        assert!(err.to_string().contains("kebab-case"));
215    }
216
217    #[test]
218    fn leading_hyphen_id_is_error() {
219        let mut rule = minimal_rule(Predicate::ScryerHealth {
220            signal: HealthSignal::IngestionLag,
221        });
222        rule.id = RuleId("-rule".into());
223        let err = validate(&rule).unwrap_err();
224        assert!(err.to_string().contains("must not start or end with a hyphen"));
225    }
226
227    #[test]
228    fn empty_notification_channels_is_error() {
229        let rule = TowerRule {
230            schema_version: SchemaVersion::V1,
231            id: RuleId("test-rule".into()),
232            name: "test".into(),
233            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
234            trigger: Trigger::Notification {
235                channels: vec![],
236                severity: Severity::Info,
237            },
238            debounce_ms: None,
239            federation: FederationPolicy::LocalOnly,
240            enabled: true,
241        };
242        let err = validate(&rule).unwrap_err();
243        assert!(err.to_string().contains("at least one notification channel"));
244    }
245
246    #[test]
247    fn empty_agent_class_is_error() {
248        let rule = TowerRule {
249            schema_version: SchemaVersion::V1,
250            id: RuleId("dispatch-rule".into()),
251            name: "test".into(),
252            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
253            trigger: Trigger::AgentDispatch {
254                agent_class: AgentClassRef("".into()),
255                prompt_template: PromptTemplate("fix: {{event}}".into()),
256                placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
257            },
258            debounce_ms: None,
259            federation: FederationPolicy::LocalOnly,
260            enabled: true,
261        };
262        let err = validate(&rule).unwrap_err();
263        assert!(err.to_string().contains("agent class ref must not be empty"));
264    }
265
266    #[test]
267    fn agent_dispatch_without_event_ref_warns() {
268        let rule = TowerRule {
269            schema_version: SchemaVersion::V1,
270            id: RuleId("dispatch-rule".into()),
271            name: "test".into(),
272            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
273            trigger: Trigger::AgentDispatch {
274                agent_class: AgentClassRef("gnome/fixer".into()),
275                prompt_template: PromptTemplate("Please fix things.".into()),
276                placement: TaskPlacement::new(TaskLocation::Local, TaskRuntime::Native),
277            },
278            debounce_ms: None,
279            federation: FederationPolicy::LocalOnly,
280            enabled: true,
281        };
282        let warnings = validate(&rule).unwrap();
283        assert!(warnings.iter().any(|w| w.path == "trigger.prompt_template"));
284    }
285
286    #[test]
287    fn scryer_health_with_mesh_wide_federation_warns() {
288        let rule = TowerRule {
289            schema_version: SchemaVersion::V1,
290            id: RuleId("health-mesh".into()),
291            name: "test".into(),
292            predicate: Predicate::ScryerHealth { signal: HealthSignal::IngestionLag },
293            trigger: Trigger::Notification {
294                channels: vec![NotificationChannel::DesktopBadge],
295                severity: Severity::Warning,
296            },
297            debounce_ms: None,
298            federation: FederationPolicy::MeshWide,
299            enabled: true,
300        };
301        let warnings = validate(&rule).unwrap();
302        assert!(warnings.iter().any(|w| w.path == "federation"));
303    }
304}