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                // LOCK 2 (at-rest, structural): a C/D-jurisdiction (detect-only) tick may carry NO
40                // Test check on any ground — a detect-only decision must not be able to gate, so it
41                // must hold no runnable test binding. A distinct invariant from no-vacuous-binding.
42                if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
43                    && t.grounds
44                        .iter()
45                        .any(|g| matches!(g.check, Some(crate::tick::Check::Test { .. })))
46                {
47                    violations.push(format!(
48                        "{filename}: a C/D jurisdiction (detect-only) tick may carry no test check"
49                    ));
50                }
51                // R3 / R5 lexical lints over the free-text fields (best-effort; a re-wording evades).
52                let mut texts = vec![t.decision.clone(), t.observe.clone()];
53                texts.extend(t.grounds.iter().map(|g| g.claim.clone()));
54                for text in &texts {
55                    for verb in crate::lint::r3_self_evolve(text) {
56                        violations.push(format!("{filename}: R3 self-evolve subject \"{verb}\" should be a human (best-effort lint)"));
57                    }
58                    for op in crate::lint::r5_forbidden_op(text) {
59                        violations.push(format!(
60                            "{filename}: R5 forbidden op language \"{op}\" (best-effort lint)"
61                        ));
62                    }
63                }
64            }
65        }
66    }
67
68    // Chain (R6): parent resolves; genesis "" ok; forward-only / acyclic.
69    for (id, parent) in &parent_of {
70        if parent.is_empty() {
71            continue;
72        }
73        if !ids.contains(parent) {
74            violations.push(format!("{id}: parent_id {parent} does not resolve (R6)"));
75        }
76    }
77    for start in parent_of.keys() {
78        let mut seen = HashSet::new();
79        let mut cur = start.clone();
80        loop {
81            if !seen.insert(cur.clone()) {
82                violations.push(format!("{start}: parent chain has a cycle (R6)"));
83                break;
84            }
85            match parent_of.get(&cur) {
86                Some(p) if !p.is_empty() && ids.contains(p) => cur = p.clone(),
87                _ => break,
88            }
89        }
90    }
91
92    Ok(violations)
93}
94
95/// Forward-compat surfacing (T3): a `warning:` (NOT a violation) per tolerated unknown top-level
96/// key, so a typo'd field name stays visible instead of silently parsing through. `schema_version`
97/// is read LAZILY here — at the tolerate-vs-reject decision — so a future reader can sharpen the
98/// rule per declared baseline without making schema_version a parsed `Config` field.
99pub fn unknown_key_warnings(store: &Store) -> std::io::Result<Vec<String>> {
100    let baseline = crate::config::schema_version(store);
101    let mut warnings = Vec::new();
102    for (filename, raw) in &store.read_all()? {
103        let Some(obj) = raw.as_object() else { continue };
104        for key in crate::tick::unknown_top_level_keys(obj) {
105            warnings.push(format!(
106                "{filename}: warning: tolerated unknown top-level field {key:?} (schema_version {baseline}) — a typo'd field name parses through but is ignored"
107            ));
108        }
109    }
110    Ok(warnings)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::canonical::compute_id;
117    use crate::store::Store;
118    use crate::tick::{Ground, Tick};
119
120    fn tmp() -> std::path::PathBuf {
121        use std::sync::atomic::{AtomicU64, Ordering};
122        static N: AtomicU64 = AtomicU64::new(0);
123        let p = std::env::temp_dir().join(format!(
124            "ev-verify-{}-{}",
125            std::process::id(),
126            N.fetch_add(1, Ordering::Relaxed)
127        ));
128        let _ = std::fs::remove_dir_all(&p);
129        std::fs::create_dir_all(&p).unwrap();
130        p
131    }
132    fn tick(parent: &str) -> Tick {
133        let mut t = Tick {
134            id: String::new(),
135            parent_id: parent.into(),
136            observe: "o".into(),
137            decision: "d".into(),
138            grounds: vec![Ground {
139                claim: "c".into(),
140                supports: "chosen".into(),
141                check: None,
142            }],
143            status: "live".into(),
144            held_since: "".into(),
145            blame: "Wang Yu".into(),
146            authority: None,
147            jurisdiction: None,
148            round_id: None,
149        };
150        t.id = compute_id(&t);
151        t
152    }
153
154    #[test]
155    fn verify_should_return_no_violations_when_the_chain_is_a_clean_two_tick_chain() {
156        // given: a store with a genesis tick and a child tick that links to it
157        let repo = tmp();
158        let s = Store::at(&repo);
159        s.init().unwrap();
160        let g = tick("");
161        s.write_tick(&g).unwrap();
162        let child = tick(&g.id);
163        s.write_tick(&child).unwrap();
164
165        // when: verify scans the store
166        let v = verify(&s).unwrap();
167
168        // then: there are no violations
169        assert!(v.is_empty());
170    }
171
172    #[test]
173    fn verify_should_flag_id_not_hash_when_a_tick_is_hand_edited_on_disk() {
174        // given: a stored genesis tick whose decision text is tampered without changing the filename/id
175        let repo = tmp();
176        let s = Store::at(&repo);
177        s.init().unwrap();
178        let g = tick("");
179        s.write_tick(&g).unwrap();
180        let p = s.ticks_dir().join(&g.id);
181        let text = std::fs::read_to_string(&p)
182            .unwrap()
183            .replace("\"d\"", "\"TAMPERED\"");
184        std::fs::write(&p, text).unwrap();
185
186        // when: verify scans the store
187        let v = verify(&s).unwrap();
188
189        // then: it reports an id != hash violation
190        assert!(v.iter().any(|x| x.contains("id != hash")));
191    }
192
193    #[test]
194    fn verify_should_flag_an_unresolved_parent_when_a_tick_points_at_a_missing_parent() {
195        // given: a store with a tick whose parent_id does not exist
196        let repo = tmp();
197        let s = Store::at(&repo);
198        s.init().unwrap();
199        let orphan = tick("deadbeefdead");
200        s.write_tick(&orphan).unwrap();
201
202        // when: verify scans the store
203        let v = verify(&s).unwrap();
204
205        // then: it reports an unresolved-parent violation
206        assert!(v.iter().any(|x| x.contains("does not resolve")));
207    }
208
209    #[test]
210    fn verify_should_flag_a_closed_schema_violation_when_the_hashed_payload_has_a_field_outside_the_schema(
211    ) {
212        // given: a stored genesis tick whose ground claim (hashed payload) is renamed on disk to an
213        // unknown field — the hashed payload stays a STRICTLY closed schema (the two-tier rule
214        // tolerates unknown TOP-LEVEL keys, never unknown keys inside the hashed payload)
215        let repo = tmp();
216        let s = Store::at(&repo);
217        s.init().unwrap();
218        let g = tick("");
219        s.write_tick(&g).unwrap();
220        let p = s.ticks_dir().join(&g.id);
221        let text = std::fs::read_to_string(&p)
222            .unwrap()
223            .replace("\"claim\"", "\"health\"");
224        std::fs::write(&p, text).unwrap();
225
226        // when: verify scans the store
227        let v = verify(&s).unwrap();
228
229        // then: it reports a closed-schema violation
230        assert!(v.iter().any(|x| x.contains("closed schema")));
231    }
232
233    #[test]
234    fn verify_should_flag_an_r3_violation_when_a_tick_decision_has_a_system_subject_self_evolve() {
235        // given: a stored tick whose decision text names the system as the subject of a self-evolve verb
236        let repo = tmp();
237        let s = Store::at(&repo);
238        s.init().unwrap();
239        let mut t = tick("");
240        t.decision = "the index will self-improve its own ranking".into();
241        t.id = compute_id(&t);
242        s.write_tick(&t).unwrap();
243
244        // when: verify scans the store
245        let v = verify(&s).unwrap();
246
247        // then: it reports an R3 self-evolve violation
248        assert!(v
249            .iter()
250            .any(|x| x.contains("self-improve") || x.to_lowercase().contains("r3")));
251    }
252
253    #[test]
254    fn verify_should_reject_a_c_tagged_tick_that_carries_a_test_check() {
255        // given: a stored tick tagged jurisdiction=C whose ground carries a Test check
256        use crate::tick::{Check, Liveness};
257        let repo = tmp();
258        let s = Store::at(&repo);
259        s.init().unwrap();
260        let mut t = tick("");
261        t.jurisdiction = Some("C".into());
262        t.grounds[0].check = Some(Check::Test {
263            reference: "pytest x".into(),
264            verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
265            counter_test: Some("pytest x::flips".into()),
266            liveness: Liveness {
267                platforms: vec!["linux-ci".into()],
268                triggered_by: vec!["f".into()],
269                surfaces: vec!["s".into()],
270            },
271        });
272        t.id = compute_id(&t);
273        s.write_tick(&t).unwrap();
274
275        // when: verify scans the store
276        let v = verify(&s).unwrap();
277
278        // then: it reports a C/D-carries-a-test violation (a detect-only jurisdiction may not gate)
279        assert!(
280            v.iter()
281                .any(|x| x.to_lowercase().contains("jurisdiction")
282                    && x.to_lowercase().contains("test")),
283            "expected a C/D-with-test violation; got: {v:?}"
284        );
285    }
286
287    #[test]
288    fn verify_should_accept_a_c_tagged_tick_when_it_carries_no_test_check() {
289        // given: a stored tick tagged jurisdiction=C whose grounds carry no Test check
290        let repo = tmp();
291        let s = Store::at(&repo);
292        s.init().unwrap();
293        let mut t = tick("");
294        t.jurisdiction = Some("C".into());
295        t.id = compute_id(&t);
296        s.write_tick(&t).unwrap();
297
298        // when: verify scans the store
299        let v = verify(&s).unwrap();
300
301        // then: there are no violations (a test-free C tick is well-formed)
302        assert!(v.is_empty(), "unexpected violations: {v:?}");
303    }
304
305    #[test]
306    fn verify_should_flag_an_empty_blame_when_a_tick_blame_is_blanked_on_disk() {
307        // given: a stored tick whose blame is blanked on disk (excluded from hash, so id stays valid)
308        let repo = tmp();
309        let s = Store::at(&repo);
310        s.init().unwrap();
311        let t = tick("");
312        s.write_tick(&t).unwrap();
313        let p = s.ticks_dir().join(&t.id);
314        let text = std::fs::read_to_string(&p)
315            .unwrap()
316            .replace("\"Wang Yu\"", "\"\"");
317        std::fs::write(&p, text).unwrap();
318
319        // when: verify scans the store
320        let v = verify(&s).unwrap();
321
322        // then: it reports an empty-blame violation
323        assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
324    }
325
326    #[test]
327    fn unknown_key_warnings_should_warn_but_not_violate_when_a_tick_carries_a_tolerated_unknown_key(
328    ) {
329        // given: a stored tick that carries a tolerated unknown (forward-compat) top-level key,
330        // added on disk — the key is non-hashed, so the content-addressed id stays valid
331        let repo = tmp();
332        let s = Store::at(&repo);
333        s.init().unwrap();
334        let t = tick("");
335        s.write_tick(&t).unwrap();
336        let p = s.ticks_dir().join(&t.id);
337        let text = std::fs::read_to_string(&p)
338            .unwrap()
339            .replace("\"blame\"", "\"future_field\": \"x\",\n  \"blame\"");
340        std::fs::write(&p, text).unwrap();
341
342        // when: verify scans the store and warnings are collected
343        let v = verify(&s).unwrap();
344        let w = unknown_key_warnings(&s).unwrap();
345
346        // then: there is no violation (the unknown key is tolerated) but a warning names the key
347        assert!(
348            v.is_empty(),
349            "a tolerated unknown key must not violate: {v:?}"
350        );
351        assert!(
352            w.iter()
353                .any(|x| x.contains("future_field") && x.contains("warning")),
354            "expected a warning naming the tolerated key; got: {w:?}"
355        );
356    }
357}