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 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 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 let v = verify(&s).unwrap();
135
136 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 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 let v = verify(&s).unwrap();
156
157 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 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 let v = verify(&s).unwrap();
172
173 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 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 let v = verify(&s).unwrap();
193
194 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 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 let v = verify(&s).unwrap();
211
212 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 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 let v = verify(&s).unwrap();
234
235 assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
237 }
238}