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                // shares one predicate with the migrate ingest gate so the two can never drift.
43                if crate::tick::detect_only_carries_test(t.jurisdiction.as_deref(), &t.grounds) {
44                    violations.push(format!(
45                        "{filename}: a C/D jurisdiction (detect-only) tick may carry no test check"
46                    ));
47                }
48                // R3 / R5 lexical lints over the free-text fields (best-effort; a re-wording evades).
49                // PROVENANCE PARTITION: only the R5 op-arm softens, and only for `imported` — a tick
50                // that faithfully transcribes historical text is not authoring a forbidden op now, so
51                // an op-word in it is surfaced as a non-gating warning (see `imported_op_warnings`),
52                // never a violation. EVERY other arm stays hard for all provenance including imported:
53                // R3 self-evolve here, plus empty-blame and C/D-no-test above. `agent-proposed` and
54                // `human-now` (the absent default) keep the R5 op-arm a hard violation.
55                let imported = t.provenance.as_deref() == Some("imported");
56                let mut texts = vec![t.decision.clone(), t.observe.clone()];
57                texts.extend(t.grounds.iter().map(|g| g.claim.clone()));
58                for text in &texts {
59                    for verb in crate::lint::r3_self_evolve(text) {
60                        violations.push(format!("{filename}: R3 self-evolve subject \"{verb}\" should be a human (best-effort lint)"));
61                    }
62                    if imported {
63                        continue; // imported R5 op-words are warnings, not violations
64                    }
65                    for op in crate::lint::r5_forbidden_op(text) {
66                        violations.push(format!(
67                            "{filename}: R5 forbidden op language \"{op}\" (best-effort lint)"
68                        ));
69                    }
70                }
71            }
72        }
73    }
74
75    // Chain (R6): parent resolves; genesis "" ok; forward-only / acyclic.
76    for (id, parent) in &parent_of {
77        if parent.is_empty() {
78            continue;
79        }
80        if !ids.contains(parent) {
81            violations.push(format!("{id}: parent_id {parent} does not resolve (R6)"));
82        }
83    }
84    for start in parent_of.keys() {
85        let mut seen = HashSet::new();
86        let mut cur = start.clone();
87        loop {
88            if !seen.insert(cur.clone()) {
89                violations.push(format!("{start}: parent chain has a cycle (R6)"));
90                break;
91            }
92            match parent_of.get(&cur) {
93                Some(p) if !p.is_empty() && ids.contains(p) => cur = p.clone(),
94                _ => break,
95            }
96        }
97    }
98
99    Ok(violations)
100}
101
102/// Forward-compat surfacing (T3): a `warning:` (NOT a violation) per tolerated unknown top-level
103/// key, so a typo'd field name stays visible instead of silently parsing through. `schema_version`
104/// is read LAZILY here — at the tolerate-vs-reject decision — so a future reader can sharpen the
105/// rule per declared baseline without making schema_version a parsed `Config` field.
106pub fn unknown_key_warnings(store: &Store) -> std::io::Result<Vec<String>> {
107    let baseline = crate::config::schema_version(store);
108    let mut warnings = Vec::new();
109    for (filename, raw) in &store.read_all()? {
110        let Some(obj) = raw.as_object() else { continue };
111        for key in crate::tick::unknown_top_level_keys(obj) {
112            warnings.push(format!(
113                "{filename}: warning: tolerated unknown top-level field {key:?} (schema_version {baseline}) — a typo'd field name parses through but is ignored"
114            ));
115        }
116    }
117    Ok(warnings)
118}
119
120/// The provenance-partitioned R5 surfacing: an `imported` tick faithfully transcribes historical text,
121/// so an R5 forbidden-op word in it is a non-gating `warning:` (recorded, not authored now), NOT a
122/// violation — `verify` skips it as a violation for imported ticks, and this surfaces it instead so the
123/// op-word stays visible with a named human still on the hook. Fresh authorship (`human-now` /
124/// `agent-proposed`) keeps the op-arm a hard violation in `verify`, and every other arm stays hard.
125pub fn imported_op_warnings(store: &Store) -> std::io::Result<Vec<String>> {
126    let mut warnings = Vec::new();
127    for (filename, raw) in &store.read_all()? {
128        let Ok(t) = from_value(raw) else { continue };
129        if t.provenance.as_deref() != Some("imported") {
130            continue;
131        }
132        let mut texts = vec![t.decision.clone(), t.observe.clone()];
133        texts.extend(t.grounds.iter().map(|g| g.claim.clone()));
134        for text in &texts {
135            for op in crate::lint::r5_forbidden_op(text) {
136                warnings.push(format!(
137                    "{filename}: warning: R5 op language \"{op}\" in imported historical text (recorded, not authored — best-effort lint)"
138                ));
139            }
140        }
141    }
142    Ok(warnings)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::canonical::compute_id;
149    use crate::store::Store;
150    use crate::tick::{Ground, Tick};
151
152    fn tmp() -> std::path::PathBuf {
153        use std::sync::atomic::{AtomicU64, Ordering};
154        static N: AtomicU64 = AtomicU64::new(0);
155        let p = std::env::temp_dir().join(format!(
156            "ev-verify-{}-{}",
157            std::process::id(),
158            N.fetch_add(1, Ordering::Relaxed)
159        ));
160        let _ = std::fs::remove_dir_all(&p);
161        std::fs::create_dir_all(&p).unwrap();
162        p
163    }
164    fn tick(parent: &str) -> Tick {
165        let mut t = Tick {
166            id: String::new(),
167            parent_id: parent.into(),
168            observe: "o".into(),
169            decision: "d".into(),
170            grounds: vec![Ground {
171                claim: "c".into(),
172                supports: "chosen".into(),
173                check: None,
174            }],
175            status: "live".into(),
176            held_since: "".into(),
177            blame: "Wang Yu".into(),
178            authority: None,
179            jurisdiction: None,
180            source_ref: None,
181            provenance: None,
182            corrects: None,
183        };
184        t.id = compute_id(&t);
185        t
186    }
187
188    #[test]
189    fn verify_should_return_no_violations_when_the_chain_is_a_clean_two_tick_chain() {
190        // given: a store with a genesis tick and a child tick that links to it
191        let repo = tmp();
192        let s = Store::at(&repo);
193        s.init().unwrap();
194        let g = tick("");
195        s.write_tick(&g).unwrap();
196        let child = tick(&g.id);
197        s.write_tick(&child).unwrap();
198
199        // when: verify scans the store
200        let v = verify(&s).unwrap();
201
202        // then: there are no violations
203        assert!(v.is_empty());
204    }
205
206    #[test]
207    fn verify_should_flag_id_not_hash_when_a_tick_is_hand_edited_on_disk() {
208        // given: a stored genesis tick whose decision text is tampered without changing the filename/id
209        let repo = tmp();
210        let s = Store::at(&repo);
211        s.init().unwrap();
212        let g = tick("");
213        s.write_tick(&g).unwrap();
214        let p = s.ticks_dir().join(&g.id);
215        let text = std::fs::read_to_string(&p)
216            .unwrap()
217            .replace("\"d\"", "\"TAMPERED\"");
218        std::fs::write(&p, text).unwrap();
219
220        // when: verify scans the store
221        let v = verify(&s).unwrap();
222
223        // then: it reports an id != hash violation
224        assert!(v.iter().any(|x| x.contains("id != hash")));
225    }
226
227    #[test]
228    fn verify_should_flag_an_unresolved_parent_when_a_tick_points_at_a_missing_parent() {
229        // given: a store with a tick whose parent_id does not exist
230        let repo = tmp();
231        let s = Store::at(&repo);
232        s.init().unwrap();
233        let orphan = tick("deadbeefdead");
234        s.write_tick(&orphan).unwrap();
235
236        // when: verify scans the store
237        let v = verify(&s).unwrap();
238
239        // then: it reports an unresolved-parent violation
240        assert!(v.iter().any(|x| x.contains("does not resolve")));
241    }
242
243    #[test]
244    fn verify_should_flag_a_closed_schema_violation_when_the_hashed_payload_has_a_field_outside_the_schema(
245    ) {
246        // given: a stored genesis tick whose ground claim (hashed payload) is renamed on disk to an
247        // unknown field — the hashed payload stays a STRICTLY closed schema (the two-tier rule
248        // tolerates unknown TOP-LEVEL keys, never unknown keys inside the hashed payload)
249        let repo = tmp();
250        let s = Store::at(&repo);
251        s.init().unwrap();
252        let g = tick("");
253        s.write_tick(&g).unwrap();
254        let p = s.ticks_dir().join(&g.id);
255        let text = std::fs::read_to_string(&p)
256            .unwrap()
257            .replace("\"claim\"", "\"health\"");
258        std::fs::write(&p, text).unwrap();
259
260        // when: verify scans the store
261        let v = verify(&s).unwrap();
262
263        // then: it reports a closed-schema violation
264        assert!(v.iter().any(|x| x.contains("closed schema")));
265    }
266
267    #[test]
268    fn verify_should_flag_an_r3_violation_when_a_tick_decision_has_a_system_subject_self_evolve() {
269        // given: a stored tick whose decision text names the system as the subject of a self-evolve verb
270        let repo = tmp();
271        let s = Store::at(&repo);
272        s.init().unwrap();
273        let mut t = tick("");
274        t.decision = "the index will self-improve its own ranking".into();
275        t.id = compute_id(&t);
276        s.write_tick(&t).unwrap();
277
278        // when: verify scans the store
279        let v = verify(&s).unwrap();
280
281        // then: it reports an R3 self-evolve violation
282        assert!(v
283            .iter()
284            .any(|x| x.contains("self-improve") || x.to_lowercase().contains("r3")));
285    }
286
287    #[test]
288    fn verify_should_reject_a_c_tagged_tick_that_carries_a_test_check() {
289        // given: a stored tick tagged jurisdiction=C whose ground carries a Test check
290        use crate::tick::{Check, Liveness};
291        let repo = tmp();
292        let s = Store::at(&repo);
293        s.init().unwrap();
294        let mut t = tick("");
295        t.jurisdiction = Some("C".into());
296        t.grounds[0].check = Some(Check::Test {
297            reference: "pytest x".into(),
298            verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
299            counter_test: Some("pytest x::flips".into()),
300            liveness: Liveness {
301                platforms: vec!["linux-ci".into()],
302                triggered_by: vec!["f".into()],
303                surfaces: vec!["s".into()],
304            },
305        });
306        t.id = compute_id(&t);
307        s.write_tick(&t).unwrap();
308
309        // when: verify scans the store
310        let v = verify(&s).unwrap();
311
312        // then: it reports a C/D-carries-a-test violation (a detect-only jurisdiction may not gate)
313        assert!(
314            v.iter()
315                .any(|x| x.to_lowercase().contains("jurisdiction")
316                    && x.to_lowercase().contains("test")),
317            "expected a C/D-with-test violation; got: {v:?}"
318        );
319    }
320
321    #[test]
322    fn verify_should_accept_a_c_tagged_tick_when_it_carries_no_test_check() {
323        // given: a stored tick tagged jurisdiction=C whose grounds carry no Test check
324        let repo = tmp();
325        let s = Store::at(&repo);
326        s.init().unwrap();
327        let mut t = tick("");
328        t.jurisdiction = Some("C".into());
329        t.id = compute_id(&t);
330        s.write_tick(&t).unwrap();
331
332        // when: verify scans the store
333        let v = verify(&s).unwrap();
334
335        // then: there are no violations (a test-free C tick is well-formed)
336        assert!(v.is_empty(), "unexpected violations: {v:?}");
337    }
338
339    // The R5 op-word the lint catches; isolated here so the provenance partition is the only variable.
340    const OP_TEXT: &str = "the stale cron tracker will auto-close after a week";
341
342    fn op_tick_with_provenance(
343        provenance: Option<&str>,
344    ) -> (std::path::PathBuf, Store, Vec<String>) {
345        let repo = tmp();
346        let s = Store::at(&repo);
347        s.init().unwrap();
348        let mut t = tick("");
349        t.decision = OP_TEXT.into();
350        t.provenance = provenance.map(String::from);
351        t.id = compute_id(&t);
352        s.write_tick(&t).unwrap();
353        let v = verify(&s).unwrap();
354        (repo, s, v)
355    }
356
357    #[test]
358    fn verify_should_warn_not_violate_on_an_op_word_when_provenance_is_imported() {
359        // given/when: an imported tick whose transcribed text carries an R5 op-word
360        let (_repo, s, v) = op_tick_with_provenance(Some("imported"));
361
362        // then: it is NOT a gating violation, and the op-word is surfaced as a non-gating warning
363        assert!(
364            !v.iter().any(|x| x.contains("R5 forbidden op")),
365            "imported history must not gate on an op-word; got: {v:?}"
366        );
367        let w = imported_op_warnings(&s).unwrap();
368        assert!(
369            w.iter()
370                .any(|x| x.contains("auto-close") && x.contains("warning")),
371            "the op-word must surface as a warning; got: {w:?}"
372        );
373    }
374
375    #[test]
376    fn verify_should_still_violate_on_an_op_word_when_provenance_is_agent_proposed() {
377        // given/when: an agent-proposed tick with the same op-word (a live agent draft, not history)
378        let (_repo, _s, v) = op_tick_with_provenance(Some("agent-proposed"));
379
380        // then: it is a hard violation (a live agent draft must not smuggle op-language)
381        assert!(
382            v.iter().any(|x| x.contains("R5 forbidden op")),
383            "agent-proposed must keep the op-arm hard; got: {v:?}"
384        );
385    }
386
387    #[test]
388    fn verify_should_still_violate_on_an_op_word_when_provenance_is_human_now() {
389        // given/when: a fresh human-now tick (absent provenance) with the same op-word
390        let (_repo, _s, v) = op_tick_with_provenance(None);
391
392        // then: it is a hard violation (fresh authorship keeps the op-arm hard)
393        assert!(
394            v.iter().any(|x| x.contains("R5 forbidden op")),
395            "human-now must keep the op-arm hard; got: {v:?}"
396        );
397    }
398
399    #[test]
400    fn verify_should_keep_empty_blame_and_c_d_no_test_hard_even_when_imported() {
401        // given: an IMPORTED tick that ALSO violates a hard arm — blanked blame and a C/D test check
402        use crate::tick::{Check, Liveness};
403        let repo = tmp();
404        let s = Store::at(&repo);
405        s.init().unwrap();
406        let mut t = tick("");
407        t.provenance = Some("imported".into());
408        t.jurisdiction = Some("C".into());
409        t.blame = "".into();
410        t.grounds[0].check = Some(Check::Test {
411            reference: "pytest x".into(),
412            verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
413            counter_test: Some("pytest x::flips".into()),
414            liveness: Liveness {
415                platforms: vec!["linux-ci".into()],
416                triggered_by: vec!["f".into()],
417                surfaces: vec!["s".into()],
418            },
419        });
420        t.id = compute_id(&t);
421        s.write_tick(&t).unwrap();
422
423        // when: verify scans the store
424        let v = verify(&s).unwrap();
425
426        // then: the hard arms still fire for imported — only the R5 lexical op-arm ever softens
427        assert!(
428            v.iter().any(|x| x.contains("empty blame")),
429            "empty-blame stays hard for imported; got: {v:?}"
430        );
431        assert!(
432            v.iter()
433                .any(|x| x.to_lowercase().contains("jurisdiction")
434                    && x.to_lowercase().contains("test")),
435            "C/D-no-test stays hard for imported; got: {v:?}"
436        );
437    }
438
439    #[test]
440    fn verify_should_flag_an_empty_blame_when_a_tick_blame_is_blanked_on_disk() {
441        // given: a stored tick whose blame is blanked on disk (excluded from hash, so id stays valid)
442        let repo = tmp();
443        let s = Store::at(&repo);
444        s.init().unwrap();
445        let t = tick("");
446        s.write_tick(&t).unwrap();
447        let p = s.ticks_dir().join(&t.id);
448        let text = std::fs::read_to_string(&p)
449            .unwrap()
450            .replace("\"Wang Yu\"", "\"\"");
451        std::fs::write(&p, text).unwrap();
452
453        // when: verify scans the store
454        let v = verify(&s).unwrap();
455
456        // then: it reports an empty-blame violation
457        assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
458    }
459
460    #[test]
461    fn unknown_key_warnings_should_warn_but_not_violate_when_a_tick_carries_a_tolerated_unknown_key(
462    ) {
463        // given: a stored tick that carries a tolerated unknown (forward-compat) top-level key,
464        // added on disk — the key is non-hashed, so the content-addressed id stays valid
465        let repo = tmp();
466        let s = Store::at(&repo);
467        s.init().unwrap();
468        let t = tick("");
469        s.write_tick(&t).unwrap();
470        let p = s.ticks_dir().join(&t.id);
471        let text = std::fs::read_to_string(&p)
472            .unwrap()
473            .replace("\"blame\"", "\"future_field\": \"x\",\n  \"blame\"");
474        std::fs::write(&p, text).unwrap();
475
476        // when: verify scans the store and warnings are collected
477        let v = verify(&s).unwrap();
478        let w = unknown_key_warnings(&s).unwrap();
479
480        // then: there is no violation (the unknown key is tolerated) but a warning names the key
481        assert!(
482            v.is_empty(),
483            "a tolerated unknown key must not violate: {v:?}"
484        );
485        assert!(
486            w.iter()
487                .any(|x| x.contains("future_field") && x.contains("warning")),
488            "expected a warning naming the tolerated key; got: {w:?}"
489        );
490    }
491}