Skip to main content

areev_trigger/
predicate.rs

1//! Predicates over grains, and boolean gates over triggers.
2//!
3//! Both reuse `areev_cal::ast::Condition` rather than introducing a language.
4//! That AST already has `And`/`Or`/`Not` with parenthesised grouping and the
5//! right `OR < AND < NOT` precedence, it already serializes as internally
6//! tagged JSON, and `areev_cal::executor::grain_matches_condition_tree` is a
7//! total evaluator for it — a missing field is false, never an error.
8//!
9//! Two constraints made this the only workable shape:
10//!
11//! - **`areev_run_core::cond` is frozen.** Its module doc states "No expression
12//!   language beyond this in v1: that is a spec-shaped surface and a standing
13//!   exclusion", repeated as a hard exclusion in the governed-agents proposal.
14//!   So Argo Events' `conditions: "(a && b) || c"` — the best syntax in the
15//!   survey — is the one thing that cannot be copied.
16//! - **New CAL syntax is an OMS conformance decision.** Adding `&&`/`||` tokens
17//!   to the lexer is a spec-level change against an external standard.
18//!
19//! Kestra's shape is the compatible alternative: a data structure with explicit
20//! combinator nodes rather than an infix string. A serialized `Condition` tree
21//! is exactly that. Knative reached the same conclusion independently, shipping
22//! its structural `all`/`any`/`not` filters ahead of its SQL dialect because the
23//! structural form was "much more stable".
24
25use areev_cal::ast::Condition;
26use areev_cal::executor::{grain_matches_condition_tree, CalGrainResult};
27use areev_core::format::deserialize::DeserializedGrain;
28
29use crate::error::{Result, TriggerError};
30
31/// Parse a predicate written as CAL `WHERE` syntax into a `Condition` tree.
32///
33/// The author writes `enabled = true AND connector = "gmail"`; we wrap it in a
34/// throwaway `RECALL` so the *existing* CAL parser does the work, then lift the
35/// condition out. No new grammar, no new lexer token, nothing for the spec to
36/// ratify.
37pub fn parse_predicate(where_src: &str) -> Result<Condition> {
38    let probe = format!("RECALL grains WHERE {where_src}");
39    let query = areev_cal::parse(&probe).map_err(|e| TriggerError::Malformed {
40        what: format!("predicate {where_src:?} does not parse: {e}"),
41    })?;
42    match query.statement {
43        areev_cal::ast::CalStatement::Recall(r) => r
44            .where_clause
45            .map(|w| w.condition)
46            .ok_or_else(|| TriggerError::Malformed { what: "predicate is empty".into() }),
47        _ => Err(TriggerError::Malformed {
48            what: format!("predicate {where_src:?} is not a filter expression"),
49        }),
50    }
51}
52
53/// Serialize a predicate for storage on a Trigger grain.
54pub fn to_value(c: &Condition) -> Result<serde_json::Value> {
55    serde_json::to_value(c)
56        .map_err(|e| TriggerError::Malformed { what: format!("predicate is not serializable: {e}") })
57}
58
59/// Read a predicate back off a Trigger grain.
60pub fn from_value(v: &serde_json::Value) -> Result<Condition> {
61    serde_json::from_value(v.clone())
62        .map_err(|e| TriggerError::Malformed { what: format!("stored predicate is unreadable: {e}") })
63}
64
65/// Does this grain satisfy the predicate?
66///
67/// Projects the stored grain into the shape the CAL evaluator expects. `score`
68/// is meaningless here — nothing ranked this grain, it either matched or it
69/// did not — so it is zero rather than an invented number.
70pub fn grain_matches(grain: &DeserializedGrain, condition: &Condition) -> bool {
71    let projected = CalGrainResult {
72        hash: grain.hash.to_hex(),
73        grain_type: grain.grain_type.as_str().to_string(),
74        score: 0.0,
75        fields: serde_json::to_value(&grain.fields).unwrap_or(serde_json::Value::Null),
76        score_breakdown: None,
77        explanation: None,
78        relative_time: None,
79        is_deterministic: false,
80        contested_by: None,
81    };
82    grain_matches_condition_tree(&projected, condition)
83}
84
85/// Evaluate a gate over member triggers that have fired.
86///
87/// The condition's field names are member ids; a member that has fired is
88/// `true`. Reusing the same `Condition` shape means `(a AND b) OR c` over
89/// triggers is written and evaluated exactly like a predicate over grains.
90pub fn gate_satisfied(condition: &Condition, fired: &[String]) -> bool {
91    let fields: serde_json::Map<String, serde_json::Value> = fired
92        .iter()
93        .map(|m| (m.clone(), serde_json::Value::Bool(true)))
94        .collect();
95    let projected = CalGrainResult {
96        hash: String::new(),
97        grain_type: "gate".into(),
98        score: 0.0,
99        fields: serde_json::Value::Object(fields),
100        score_breakdown: None,
101        explanation: None,
102        relative_time: None,
103        is_deterministic: false,
104        contested_by: None,
105    };
106    grain_matches_condition_tree(&projected, condition)
107}
108
109/// Every field name a gate expression references.
110///
111/// Used to refuse a composite whose expression names a member it does not
112/// declare — otherwise the gate can never be satisfied and the trigger is
113/// silently dead, which is the failure mode with no symptom.
114pub fn referenced_members(condition: &Condition) -> Vec<String> {
115    fn walk(c: &Condition, out: &mut Vec<String>) {
116        match c {
117            Condition::Comparison { field, .. }
118            | Condition::In { field, .. }
119            | Condition::NotIn { field, .. }
120            | Condition::IsNull { field, .. }
121            | Condition::IsNotNull { field, .. }
122            | Condition::Contains { field, .. }
123            | Condition::StartsWith { field, .. }
124            | Condition::IsCategory { field, .. } => out.push(field.clone()),
125            Condition::And { left, right, .. } | Condition::Or { left, right, .. } => {
126                walk(left, out);
127                walk(right, out);
128            }
129            Condition::Not { inner, .. } => walk(inner, out),
130        }
131    }
132    let mut out = Vec::new();
133    walk(condition, &mut out);
134    out.sort();
135    out.dedup();
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn gate(src: &str) -> Condition {
144        parse_predicate(src).unwrap()
145    }
146
147    #[test]
148    fn boolean_composition_round_trips_through_storage() {
149        // Behaviour, not bytes: source spans are `#[serde(skip)]`, so a stored
150        // predicate legitimately comes back without them. What has to survive
151        // is what it decides.
152        let c = gate("(a = true AND b = true) OR c = true");
153        let back = from_value(&to_value(&c).unwrap()).unwrap();
154
155        for fired in [
156            vec![],
157            vec!["a".to_string()],
158            vec!["c".to_string()],
159            vec!["a".to_string(), "b".to_string()],
160            vec!["a".to_string(), "b".to_string(), "c".to_string()],
161        ] {
162            assert_eq!(
163                gate_satisfied(&c, &fired),
164                gate_satisfied(&back, &fired),
165                "storage changed the meaning for {fired:?}"
166            );
167        }
168        assert_eq!(referenced_members(&c), referenced_members(&back));
169    }
170
171    #[test]
172    fn and_requires_both_members() {
173        let c = gate("a = true AND b = true");
174        assert!(!gate_satisfied(&c, &["a".into()]));
175        assert!(!gate_satisfied(&c, &["b".into()]));
176        assert!(gate_satisfied(&c, &["a".into(), "b".into()]));
177    }
178
179    #[test]
180    fn or_requires_either() {
181        let c = gate("a = true OR b = true");
182        assert!(gate_satisfied(&c, &["a".into()]));
183        assert!(gate_satisfied(&c, &["b".into()]));
184        assert!(!gate_satisfied(&c, &[]));
185    }
186
187    #[test]
188    fn parenthesised_precedence_matches_the_written_expression() {
189        // The Argo Events shape, in Kestra's form: (a && b) || c.
190        let c = gate("(a = true AND b = true) OR c = true");
191        assert!(gate_satisfied(&c, &["c".into()]), "c alone satisfies the right branch");
192        assert!(gate_satisfied(&c, &["a".into(), "b".into()]));
193        assert!(!gate_satisfied(&c, &["a".into()]), "a alone satisfies neither branch");
194    }
195
196    #[test]
197    fn precedence_is_or_below_and_not_left_to_right() {
198        // `a AND b OR c` must parse as `(a AND b) OR c`. Read left-to-right it
199        // would be `a AND (b OR c)`, which `a` alone would then fail and `c`
200        // alone would also fail — so the two readings genuinely differ.
201        let c = gate("a = true AND b = true OR c = true");
202        assert!(gate_satisfied(&c, &["c".into()]), "c alone must satisfy it");
203    }
204
205    #[test]
206    fn negation_works() {
207        let c = gate("NOT (a = true)");
208        assert!(gate_satisfied(&c, &[]), "a absent means the negation holds");
209        assert!(!gate_satisfied(&c, &["a".into()]));
210    }
211
212    #[test]
213    fn referenced_members_finds_every_name() {
214        let c = gate("(a = true AND b = true) OR NOT (c = true)");
215        assert_eq!(referenced_members(&c), vec!["a", "b", "c"]);
216    }
217
218    #[test]
219    fn a_predicate_that_does_not_parse_is_refused_with_its_code() {
220        let e = parse_predicate("this is not a predicate").unwrap_err();
221        assert_eq!(e.code(), "TRG-E001");
222    }
223
224    #[test]
225    fn an_empty_predicate_is_refused() {
226        // A gate that matches everything is a trigger that fires constantly.
227        assert!(parse_predicate("").is_err());
228    }
229}