Skip to main content

ev/
verify.rs

1//! ev verify: R1 (closed schema), R2 (check shape), R4/R6 (id == hash + chain
2//! integrity), R3 (self-evolve subject) + R5 (blame present + forbidden-op).
3use crate::canonical::compute_id;
4use crate::store::Store;
5use crate::tick::from_value;
6use std::collections::{HashMap, HashSet};
7
8/// Returns the list of violations (empty == clean). Reports ALL of them.
9pub fn verify(store: &Store) -> std::io::Result<Vec<String>> {
10    let mut violations = Vec::new();
11    let files = store.read_all()?;
12    let mut ids: HashSet<String> = HashSet::new();
13    let mut parent_of: HashMap<String, String> = HashMap::new();
14
15    for (filename, raw) in &files {
16        match from_value(raw) {
17            Err(e) => violations.push(format!("{filename}: R1/R2 {e}")),
18            Ok(t) => {
19                let recomputed = compute_id(&t);
20                if recomputed != *filename {
21                    violations.push(format!(
22                        "{filename}: id != hash(payload) (R4/R6) — recomputed {recomputed}"
23                    ));
24                }
25                if t.id != *filename {
26                    violations.push(format!(
27                        "{filename}: stored id field {} != filename (R6)",
28                        t.id
29                    ));
30                }
31                ids.insert(filename.clone());
32                parent_of.insert(filename.clone(), t.parent_id.clone());
33                // R5: every tick names a human.
34                if t.blame.trim().is_empty() {
35                    violations.push(format!(
36                        "{filename}: empty blame (R5) — every mutating op names a human"
37                    ));
38                }
39                // R3 / R5 lexical lints over the free-text fields (best-effort; a re-wording evades).
40                let mut texts = vec![t.decision.clone(), t.observe.clone()];
41                texts.extend(t.grounds.iter().map(|g| g.claim.clone()));
42                for text in &texts {
43                    for verb in crate::lint::r3_self_evolve(text) {
44                        violations.push(format!("{filename}: R3 self-evolve subject \"{verb}\" should be a human (best-effort lint)"));
45                    }
46                    for op in crate::lint::r5_forbidden_op(text) {
47                        violations.push(format!(
48                            "{filename}: R5 forbidden op language \"{op}\" (best-effort lint)"
49                        ));
50                    }
51                }
52            }
53        }
54    }
55
56    // Chain (R6): parent resolves; genesis "" ok; forward-only / acyclic.
57    for (id, parent) in &parent_of {
58        if parent.is_empty() {
59            continue;
60        }
61        if !ids.contains(parent) {
62            violations.push(format!("{id}: parent_id {parent} does not resolve (R6)"));
63        }
64    }
65    for start in parent_of.keys() {
66        let mut seen = HashSet::new();
67        let mut cur = start.clone();
68        loop {
69            if !seen.insert(cur.clone()) {
70                violations.push(format!("{start}: parent chain has a cycle (R6)"));
71                break;
72            }
73            match parent_of.get(&cur) {
74                Some(p) if !p.is_empty() && ids.contains(p) => cur = p.clone(),
75                _ => break,
76            }
77        }
78    }
79
80    Ok(violations)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::canonical::compute_id;
87    use crate::store::Store;
88    use crate::tick::{Ground, Tick};
89
90    fn tmp() -> std::path::PathBuf {
91        use std::sync::atomic::{AtomicU64, Ordering};
92        static N: AtomicU64 = AtomicU64::new(0);
93        let p = std::env::temp_dir().join(format!(
94            "ev-verify-{}-{}",
95            std::process::id(),
96            N.fetch_add(1, Ordering::Relaxed)
97        ));
98        let _ = std::fs::remove_dir_all(&p);
99        std::fs::create_dir_all(&p).unwrap();
100        p
101    }
102    fn tick(parent: &str) -> Tick {
103        let mut t = Tick {
104            id: String::new(),
105            parent_id: parent.into(),
106            observe: "o".into(),
107            decision: "d".into(),
108            grounds: vec![Ground {
109                claim: "c".into(),
110                supports: "chosen".into(),
111                check: None,
112            }],
113            status: "live".into(),
114            held_since: "".into(),
115            blame: "Wang Yu".into(),
116            authority: None,
117        };
118        t.id = compute_id(&t);
119        t
120    }
121
122    #[test]
123    fn verify_should_return_no_violations_when_the_chain_is_a_clean_two_tick_chain() {
124        // given: a store with a genesis tick and a child tick that links to it
125        let repo = tmp();
126        let s = Store::at(&repo);
127        s.init().unwrap();
128        let g = tick("");
129        s.write_tick(&g).unwrap();
130        let child = tick(&g.id);
131        s.write_tick(&child).unwrap();
132
133        // when: verify scans the store
134        let v = verify(&s).unwrap();
135
136        // then: there are no violations
137        assert!(v.is_empty());
138    }
139
140    #[test]
141    fn verify_should_flag_id_not_hash_when_a_tick_is_hand_edited_on_disk() {
142        // given: a stored genesis tick whose decision text is tampered without changing the filename/id
143        let repo = tmp();
144        let s = Store::at(&repo);
145        s.init().unwrap();
146        let g = tick("");
147        s.write_tick(&g).unwrap();
148        let p = s.ticks_dir().join(&g.id);
149        let text = std::fs::read_to_string(&p)
150            .unwrap()
151            .replace("\"d\"", "\"TAMPERED\"");
152        std::fs::write(&p, text).unwrap();
153
154        // when: verify scans the store
155        let v = verify(&s).unwrap();
156
157        // then: it reports an id != hash violation
158        assert!(v.iter().any(|x| x.contains("id != hash")));
159    }
160
161    #[test]
162    fn verify_should_flag_an_unresolved_parent_when_a_tick_points_at_a_missing_parent() {
163        // given: a store with a tick whose parent_id does not exist
164        let repo = tmp();
165        let s = Store::at(&repo);
166        s.init().unwrap();
167        let orphan = tick("deadbeefdead");
168        s.write_tick(&orphan).unwrap();
169
170        // when: verify scans the store
171        let v = verify(&s).unwrap();
172
173        // then: it reports an unresolved-parent violation
174        assert!(v.iter().any(|x| x.contains("does not resolve")));
175    }
176
177    #[test]
178    fn verify_should_flag_a_closed_schema_violation_when_a_tick_has_a_field_outside_the_schema() {
179        // given: a stored genesis tick whose status field is renamed on disk to an unknown field
180        let repo = tmp();
181        let s = Store::at(&repo);
182        s.init().unwrap();
183        let g = tick("");
184        s.write_tick(&g).unwrap();
185        let p = s.ticks_dir().join(&g.id);
186        let text = std::fs::read_to_string(&p)
187            .unwrap()
188            .replace("\"status\"", "\"health\"");
189        std::fs::write(&p, text).unwrap();
190
191        // when: verify scans the store
192        let v = verify(&s).unwrap();
193
194        // then: it reports a closed-schema violation
195        assert!(v.iter().any(|x| x.contains("closed schema")));
196    }
197
198    #[test]
199    fn verify_should_flag_an_r3_violation_when_a_tick_decision_has_a_system_subject_self_evolve() {
200        // given: a stored tick whose decision text names the system as the subject of a self-evolve verb
201        let repo = tmp();
202        let s = Store::at(&repo);
203        s.init().unwrap();
204        let mut t = tick("");
205        t.decision = "the index will self-improve its own ranking".into();
206        t.id = compute_id(&t);
207        s.write_tick(&t).unwrap();
208
209        // when: verify scans the store
210        let v = verify(&s).unwrap();
211
212        // then: it reports an R3 self-evolve violation
213        assert!(v
214            .iter()
215            .any(|x| x.contains("self-improve") || x.to_lowercase().contains("r3")));
216    }
217
218    #[test]
219    fn verify_should_flag_an_empty_blame_when_a_tick_blame_is_blanked_on_disk() {
220        // given: a stored tick whose blame is blanked on disk (excluded from hash, so id stays valid)
221        let repo = tmp();
222        let s = Store::at(&repo);
223        s.init().unwrap();
224        let t = tick("");
225        s.write_tick(&t).unwrap();
226        let p = s.ticks_dir().join(&t.id);
227        let text = std::fs::read_to_string(&p)
228            .unwrap()
229            .replace("\"Wang Yu\"", "\"\"");
230        std::fs::write(&p, text).unwrap();
231
232        // when: verify scans the store
233        let v = verify(&s).unwrap();
234
235        // then: it reports an empty-blame violation
236        assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
237    }
238}