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.
90///
91/// **Every referenced member is materialized**, `false` when it has not fired.
92/// The two callers of the shared evaluator mean opposite things by an absent
93/// field: over a *grain* absence is UNKNOWN (the type does not carry the
94/// field, so the predicate has no answer — #207), while over a *gate* absence
95/// is a definite "has not fired". Projecting only the fired members left the
96/// evaluator to guess between them, and `NOT (a = true)` — "fire when `a` did
97/// not" — is exactly where the two readings diverge. Naming every member
98/// makes the gate's meaning explicit instead of encoding it in a gap.
99pub fn gate_satisfied(condition: &Condition, fired: &[String]) -> bool {
100    let fields: serde_json::Map<String, serde_json::Value> = referenced_members(condition)
101        .into_iter()
102        .map(|m| {
103            let has_fired = fired.contains(&m);
104            (m, serde_json::Value::Bool(has_fired))
105        })
106        .collect();
107    let projected = CalGrainResult {
108        hash: String::new(),
109        grain_type: "gate".into(),
110        score: 0.0,
111        fields: serde_json::Value::Object(fields),
112        score_breakdown: None,
113        explanation: None,
114        relative_time: None,
115        is_deterministic: false,
116        contested_by: None,
117    };
118    grain_matches_condition_tree(&projected, condition)
119}
120
121/// Every field name a gate expression references.
122///
123/// Used to refuse a composite whose expression names a member it does not
124/// declare — otherwise the gate can never be satisfied and the trigger is
125/// silently dead, which is the failure mode with no symptom.
126pub fn referenced_members(condition: &Condition) -> Vec<String> {
127    fn walk(c: &Condition, out: &mut Vec<String>) {
128        match c {
129            Condition::Comparison { field, .. }
130            | Condition::In { field, .. }
131            | Condition::NotIn { field, .. }
132            | Condition::IsNull { field, .. }
133            | Condition::IsNotNull { field, .. }
134            | Condition::Contains { field, .. }
135            | Condition::StartsWith { field, .. }
136            | Condition::IsCategory { field, .. } => out.push(field.clone()),
137            Condition::And { left, right, .. } | Condition::Or { left, right, .. } => {
138                walk(left, out);
139                walk(right, out);
140            }
141            Condition::Not { inner, .. } => walk(inner, out),
142        }
143    }
144    let mut out = Vec::new();
145    walk(condition, &mut out);
146    out.sort();
147    out.dedup();
148    out
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn gate(src: &str) -> Condition {
156        parse_predicate(src).unwrap()
157    }
158
159    #[test]
160    fn boolean_composition_round_trips_through_storage() {
161        // Behaviour, not bytes: source spans are `#[serde(skip)]`, so a stored
162        // predicate legitimately comes back without them. What has to survive
163        // is what it decides.
164        let c = gate("(a = true AND b = true) OR c = true");
165        let back = from_value(&to_value(&c).unwrap()).unwrap();
166
167        for fired in [
168            vec![],
169            vec!["a".to_string()],
170            vec!["c".to_string()],
171            vec!["a".to_string(), "b".to_string()],
172            vec!["a".to_string(), "b".to_string(), "c".to_string()],
173        ] {
174            assert_eq!(
175                gate_satisfied(&c, &fired),
176                gate_satisfied(&back, &fired),
177                "storage changed the meaning for {fired:?}"
178            );
179        }
180        assert_eq!(referenced_members(&c), referenced_members(&back));
181    }
182
183    #[test]
184    fn and_requires_both_members() {
185        let c = gate("a = true AND b = true");
186        assert!(!gate_satisfied(&c, &["a".into()]));
187        assert!(!gate_satisfied(&c, &["b".into()]));
188        assert!(gate_satisfied(&c, &["a".into(), "b".into()]));
189    }
190
191    #[test]
192    fn or_requires_either() {
193        let c = gate("a = true OR b = true");
194        assert!(gate_satisfied(&c, &["a".into()]));
195        assert!(gate_satisfied(&c, &["b".into()]));
196        assert!(!gate_satisfied(&c, &[]));
197    }
198
199    #[test]
200    fn parenthesised_precedence_matches_the_written_expression() {
201        // The Argo Events shape, in Kestra's form: (a && b) || c.
202        let c = gate("(a = true AND b = true) OR c = true");
203        assert!(gate_satisfied(&c, &["c".into()]), "c alone satisfies the right branch");
204        assert!(gate_satisfied(&c, &["a".into(), "b".into()]));
205        assert!(!gate_satisfied(&c, &["a".into()]), "a alone satisfies neither branch");
206    }
207
208    #[test]
209    fn precedence_is_or_below_and_not_left_to_right() {
210        // `a AND b OR c` must parse as `(a AND b) OR c`. Read left-to-right it
211        // would be `a AND (b OR c)`, which `a` alone would then fail and `c`
212        // alone would also fail — so the two readings genuinely differ.
213        let c = gate("a = true AND b = true OR c = true");
214        assert!(gate_satisfied(&c, &["c".into()]), "c alone must satisfy it");
215    }
216
217    #[test]
218    fn negation_works() {
219        let c = gate("NOT (a = true)");
220        assert!(gate_satisfied(&c, &[]), "a absent means the negation holds");
221        assert!(!gate_satisfied(&c, &["a".into()]));
222    }
223
224    #[test]
225    fn referenced_members_finds_every_name() {
226        let c = gate("(a = true AND b = true) OR NOT (c = true)");
227        assert_eq!(referenced_members(&c), vec!["a", "b", "c"]);
228    }
229
230    #[test]
231    fn a_predicate_that_does_not_parse_is_refused_with_its_code() {
232        let e = parse_predicate("this is not a predicate").unwrap_err();
233        assert_eq!(e.code(), "TRG-E001");
234    }
235
236    #[test]
237    fn an_empty_predicate_is_refused() {
238        // A gate that matches everything is a trigger that fires constantly.
239        assert!(parse_predicate("").is_err());
240    }
241}