Skip to main content

areev_loop/
cal.rs

1//! A tiny CAL *writer*. The engine never parses CAL (that is the substrate's
2//! job — `validate_cal`/`execute_cal`); it only emits the handful of statements
3//! built-in analyzers propose. Statements are newline-separated to form a
4//! batch. Keeping this a writer, not a parser, is what lets the engine claim
5//! zero CAL-grammar ownership (proposal §10).
6
7use serde_json::{Map, Value};
8
9/// `FORGET <hash>` — the only destructive statement the writer emits,
10/// single-grain (§6.4).
11pub fn forget(hash: &str) -> String {
12    format!("FORGET {hash}")
13}
14
15/// `ADD <type> {json}` — a Tier-1 non-destructive evolve write.
16pub fn add(grain_type: &str, fields: &Map<String, Value>) -> String {
17    format!("ADD {grain_type} {}", Value::Object(fields.clone()))
18}
19
20/// `SUPERSEDE <hash> WITH <type> {json}` — replace a head non-destructively.
21pub fn supersede(target_hash: &str, grain_type: &str, fields: &Map<String, Value>) -> String {
22    format!(
23        "SUPERSEDE {target_hash} WITH {grain_type} {}",
24        Value::Object(fields.clone())
25    )
26}
27
28/// Join statements into a batch.
29pub fn batch(statements: &[String]) -> String {
30    statements.join("\n")
31}
32
33/// Round-trip a line this module's own [`supersede`] emitted back into
34/// `(target_hash, grain_type, fields)`. This is a strict inverse of the
35/// writer's own output — **not** a CAL parser (any other shape returns
36/// `None`; the substrate's grammar stays authoritative). The auto-apply gate
37/// uses it to value-verify a replacement against the grain it supersedes.
38pub fn parse_own_supersede(
39    line: &str,
40) -> Option<(String, String, Map<String, Value>)> {
41    let rest = line.trim().strip_prefix("SUPERSEDE ")?;
42    let (target, after) = rest.split_once(" WITH ")?;
43    let (grain_type, json) = after.trim().split_once(' ')?;
44    match serde_json::from_str(json.trim()).ok()? {
45        Value::Object(fields) => {
46            Some((target.trim().to_string(), grain_type.to_string(), fields))
47        }
48        _ => None,
49    }
50}
51
52/// Cheap engine-side destructive check (defense in depth; the substrate's
53/// `validate_cal` is authoritative). True if any statement is a FORGET.
54pub fn contains_forget(cal: &str) -> bool {
55    any_line_keyword(cal, &["FORGET"])
56}
57
58/// True if any statement is destructive: FORGET (single-grain or SUBJECT)
59/// or PURGE. `Recommendation::destructive` is stamped from this and the
60/// apply gate (admin scope + `allow_destructive`) keys off that stamp, so
61/// this list must cover every destructive statement a proposal could carry —
62/// LLM-enriched and `--analyzer-cmd` proposals are arbitrary CAL text, not
63/// just what this writer emits.
64pub fn contains_destructive(cal: &str) -> bool {
65    any_line_keyword(cal, &["FORGET", "PURGE"])
66}
67
68/// True if any line's leading keyword (case-insensitive) is in `keywords`.
69fn any_line_keyword(cal: &str, keywords: &[&str]) -> bool {
70    cal.lines().any(|l| {
71        let kw = l
72            .trim_start()
73            .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
74            .next()
75            .unwrap_or("");
76        keywords.iter().any(|k| kw.eq_ignore_ascii_case(k))
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use serde_json::json;
84
85    #[test]
86    fn forget_is_detected() {
87        assert!(contains_forget("FORGET sha256:abc"));
88        assert!(contains_forget("ADD fact {}\nforget sha256:x"));
89        assert!(!contains_forget("ADD fact {}\nSUPERSEDE a WITH fact {}"));
90    }
91
92    #[test]
93    fn destructive_covers_purge_and_forget_subject() {
94        assert!(contains_destructive("FORGET sha256:abc"));
95        assert!(contains_destructive(r#"FORGET SUBJECT "pat" BECAUSE "gdpr""#));
96        assert!(contains_destructive(r#"PURGE OLDER THAN 90d BECAUSE "retention""#));
97        assert!(contains_destructive("ADD fact {}\n  purge older than 30d because \"x\""));
98        assert!(!contains_destructive("ADD fact {}\nSUPERSEDE a WITH fact {}"));
99        // Keyword match, not substring: reads that mention the words don't trip it.
100        assert!(!contains_destructive(r#"RECALL facts WHERE subject = "purge""#));
101    }
102
103    #[test]
104    fn add_and_supersede_shapes() {
105        let mut f = Map::new();
106        f.insert("subject".into(), json!("acme"));
107        assert!(add("fact", &f).starts_with("ADD fact {"));
108        assert!(supersede("sha256:x", "fact", &f).starts_with("SUPERSEDE sha256:x WITH fact {"));
109    }
110
111    #[test]
112    fn parse_own_supersede_round_trips_the_writer() {
113        let mut f = Map::new();
114        f.insert("subject".into(), json!("acme"));
115        f.insert("object".into(), json!("Enterprise"));
116        let line = supersede("sha256:abc", "fact", &f);
117        let (target, gtype, fields) = parse_own_supersede(&line).expect("round-trip");
118        assert_eq!(target, "sha256:abc");
119        assert_eq!(gtype, "fact");
120        assert_eq!(fields, f);
121    }
122
123    #[test]
124    fn parse_own_supersede_rejects_other_shapes() {
125        assert!(parse_own_supersede("ADD fact {}").is_none());
126        assert!(parse_own_supersede("SUPERSEDE x WITH fact").is_none(), "no json");
127        assert!(parse_own_supersede("SUPERSEDE x WITH fact []").is_none(), "non-object json");
128        assert!(parse_own_supersede("FORGET x").is_none());
129    }
130}