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 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 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 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
95pub 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 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 let v = verify(&s).unwrap();
167
168 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 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 let v = verify(&s).unwrap();
188
189 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 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 let v = verify(&s).unwrap();
204
205 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 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 let v = verify(&s).unwrap();
228
229 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 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 let v = verify(&s).unwrap();
246
247 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 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 let v = verify(&s).unwrap();
277
278 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 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 let v = verify(&s).unwrap();
300
301 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 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 let v = verify(&s).unwrap();
321
322 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 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 let v = verify(&s).unwrap();
344 let w = unknown_key_warnings(&s).unwrap();
345
346 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}