1use crate::canonical::compute_id;
4use crate::store::Store;
5use crate::tick::from_value;
6use std::collections::{HashMap, HashSet};
7
8pub 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 if t.blame.trim().is_empty() {
35 violations.push(format!(
36 "{filename}: empty blame (R5) — every mutating op names a human"
37 ));
38 }
39 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 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 };
117 t.id = compute_id(&t);
118 t
119 }
120
121 #[test]
122 fn verify_should_return_no_violations_when_the_chain_is_a_clean_two_tick_chain() {
123 let repo = tmp();
125 let s = Store::at(&repo);
126 s.init().unwrap();
127 let g = tick("");
128 s.write_tick(&g).unwrap();
129 let child = tick(&g.id);
130 s.write_tick(&child).unwrap();
131
132 let v = verify(&s).unwrap();
134
135 assert!(v.is_empty());
137 }
138
139 #[test]
140 fn verify_should_flag_id_not_hash_when_a_tick_is_hand_edited_on_disk() {
141 let repo = tmp();
143 let s = Store::at(&repo);
144 s.init().unwrap();
145 let g = tick("");
146 s.write_tick(&g).unwrap();
147 let p = s.ticks_dir().join(&g.id);
148 let text = std::fs::read_to_string(&p)
149 .unwrap()
150 .replace("\"d\"", "\"TAMPERED\"");
151 std::fs::write(&p, text).unwrap();
152
153 let v = verify(&s).unwrap();
155
156 assert!(v.iter().any(|x| x.contains("id != hash")));
158 }
159
160 #[test]
161 fn verify_should_flag_an_unresolved_parent_when_a_tick_points_at_a_missing_parent() {
162 let repo = tmp();
164 let s = Store::at(&repo);
165 s.init().unwrap();
166 let orphan = tick("deadbeefdead");
167 s.write_tick(&orphan).unwrap();
168
169 let v = verify(&s).unwrap();
171
172 assert!(v.iter().any(|x| x.contains("does not resolve")));
174 }
175
176 #[test]
177 fn verify_should_flag_a_closed_schema_violation_when_a_tick_has_a_field_outside_the_schema() {
178 let repo = tmp();
180 let s = Store::at(&repo);
181 s.init().unwrap();
182 let g = tick("");
183 s.write_tick(&g).unwrap();
184 let p = s.ticks_dir().join(&g.id);
185 let text = std::fs::read_to_string(&p)
186 .unwrap()
187 .replace("\"status\"", "\"health\"");
188 std::fs::write(&p, text).unwrap();
189
190 let v = verify(&s).unwrap();
192
193 assert!(v.iter().any(|x| x.contains("closed schema")));
195 }
196
197 #[test]
198 fn verify_should_flag_an_r3_violation_when_a_tick_decision_has_a_system_subject_self_evolve() {
199 let repo = tmp();
201 let s = Store::at(&repo);
202 s.init().unwrap();
203 let mut t = tick("");
204 t.decision = "the index will self-improve its own ranking".into();
205 t.id = compute_id(&t);
206 s.write_tick(&t).unwrap();
207
208 let v = verify(&s).unwrap();
210
211 assert!(v
213 .iter()
214 .any(|x| x.contains("self-improve") || x.to_lowercase().contains("r3")));
215 }
216
217 #[test]
218 fn verify_should_flag_an_empty_blame_when_a_tick_blame_is_blanked_on_disk() {
219 let repo = tmp();
221 let s = Store::at(&repo);
222 s.init().unwrap();
223 let t = tick("");
224 s.write_tick(&t).unwrap();
225 let p = s.ticks_dir().join(&t.id);
226 let text = std::fs::read_to_string(&p)
227 .unwrap()
228 .replace("\"Wang Yu\"", "\"\"");
229 std::fs::write(&p, text).unwrap();
230
231 let v = verify(&s).unwrap();
233
234 assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
236 }
237}