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 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 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; }
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 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
102pub 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
120pub 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 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 let v = verify(&s).unwrap();
201
202 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 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 let v = verify(&s).unwrap();
222
223 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 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 let v = verify(&s).unwrap();
238
239 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 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 let v = verify(&s).unwrap();
262
263 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 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 let v = verify(&s).unwrap();
280
281 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 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 let v = verify(&s).unwrap();
311
312 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 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 let v = verify(&s).unwrap();
334
335 assert!(v.is_empty(), "unexpected violations: {v:?}");
337 }
338
339 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 let (_repo, s, v) = op_tick_with_provenance(Some("imported"));
361
362 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 let (_repo, _s, v) = op_tick_with_provenance(Some("agent-proposed"));
379
380 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 let (_repo, _s, v) = op_tick_with_provenance(None);
391
392 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 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 let v = verify(&s).unwrap();
425
426 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 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 let v = verify(&s).unwrap();
455
456 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 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 let v = verify(&s).unwrap();
478 let w = unknown_key_warnings(&s).unwrap();
479
480 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}