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 };
183 t.id = compute_id(&t);
184 t
185 }
186
187 #[test]
188 fn verify_should_return_no_violations_when_the_chain_is_a_clean_two_tick_chain() {
189 let repo = tmp();
191 let s = Store::at(&repo);
192 s.init().unwrap();
193 let g = tick("");
194 s.write_tick(&g).unwrap();
195 let child = tick(&g.id);
196 s.write_tick(&child).unwrap();
197
198 let v = verify(&s).unwrap();
200
201 assert!(v.is_empty());
203 }
204
205 #[test]
206 fn verify_should_flag_id_not_hash_when_a_tick_is_hand_edited_on_disk() {
207 let repo = tmp();
209 let s = Store::at(&repo);
210 s.init().unwrap();
211 let g = tick("");
212 s.write_tick(&g).unwrap();
213 let p = s.ticks_dir().join(&g.id);
214 let text = std::fs::read_to_string(&p)
215 .unwrap()
216 .replace("\"d\"", "\"TAMPERED\"");
217 std::fs::write(&p, text).unwrap();
218
219 let v = verify(&s).unwrap();
221
222 assert!(v.iter().any(|x| x.contains("id != hash")));
224 }
225
226 #[test]
227 fn verify_should_flag_an_unresolved_parent_when_a_tick_points_at_a_missing_parent() {
228 let repo = tmp();
230 let s = Store::at(&repo);
231 s.init().unwrap();
232 let orphan = tick("deadbeefdead");
233 s.write_tick(&orphan).unwrap();
234
235 let v = verify(&s).unwrap();
237
238 assert!(v.iter().any(|x| x.contains("does not resolve")));
240 }
241
242 #[test]
243 fn verify_should_flag_a_closed_schema_violation_when_the_hashed_payload_has_a_field_outside_the_schema(
244 ) {
245 let repo = tmp();
249 let s = Store::at(&repo);
250 s.init().unwrap();
251 let g = tick("");
252 s.write_tick(&g).unwrap();
253 let p = s.ticks_dir().join(&g.id);
254 let text = std::fs::read_to_string(&p)
255 .unwrap()
256 .replace("\"claim\"", "\"health\"");
257 std::fs::write(&p, text).unwrap();
258
259 let v = verify(&s).unwrap();
261
262 assert!(v.iter().any(|x| x.contains("closed schema")));
264 }
265
266 #[test]
267 fn verify_should_flag_an_r3_violation_when_a_tick_decision_has_a_system_subject_self_evolve() {
268 let repo = tmp();
270 let s = Store::at(&repo);
271 s.init().unwrap();
272 let mut t = tick("");
273 t.decision = "the index will self-improve its own ranking".into();
274 t.id = compute_id(&t);
275 s.write_tick(&t).unwrap();
276
277 let v = verify(&s).unwrap();
279
280 assert!(v
282 .iter()
283 .any(|x| x.contains("self-improve") || x.to_lowercase().contains("r3")));
284 }
285
286 #[test]
287 fn verify_should_reject_a_c_tagged_tick_that_carries_a_test_check() {
288 use crate::tick::{Check, Liveness};
290 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.grounds[0].check = Some(Check::Test {
296 reference: "pytest x".into(),
297 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
298 counter_test: Some("pytest x::flips".into()),
299 liveness: Liveness {
300 platforms: vec!["linux-ci".into()],
301 triggered_by: vec!["f".into()],
302 surfaces: vec!["s".into()],
303 },
304 });
305 t.id = compute_id(&t);
306 s.write_tick(&t).unwrap();
307
308 let v = verify(&s).unwrap();
310
311 assert!(
313 v.iter()
314 .any(|x| x.to_lowercase().contains("jurisdiction")
315 && x.to_lowercase().contains("test")),
316 "expected a C/D-with-test violation; got: {v:?}"
317 );
318 }
319
320 #[test]
321 fn verify_should_accept_a_c_tagged_tick_when_it_carries_no_test_check() {
322 let repo = tmp();
324 let s = Store::at(&repo);
325 s.init().unwrap();
326 let mut t = tick("");
327 t.jurisdiction = Some("C".into());
328 t.id = compute_id(&t);
329 s.write_tick(&t).unwrap();
330
331 let v = verify(&s).unwrap();
333
334 assert!(v.is_empty(), "unexpected violations: {v:?}");
336 }
337
338 const OP_TEXT: &str = "the stale cron tracker will auto-close after a week";
340
341 fn op_tick_with_provenance(
342 provenance: Option<&str>,
343 ) -> (std::path::PathBuf, Store, Vec<String>) {
344 let repo = tmp();
345 let s = Store::at(&repo);
346 s.init().unwrap();
347 let mut t = tick("");
348 t.decision = OP_TEXT.into();
349 t.provenance = provenance.map(String::from);
350 t.id = compute_id(&t);
351 s.write_tick(&t).unwrap();
352 let v = verify(&s).unwrap();
353 (repo, s, v)
354 }
355
356 #[test]
357 fn verify_should_warn_not_violate_on_an_op_word_when_provenance_is_imported() {
358 let (_repo, s, v) = op_tick_with_provenance(Some("imported"));
360
361 assert!(
363 !v.iter().any(|x| x.contains("R5 forbidden op")),
364 "imported history must not gate on an op-word; got: {v:?}"
365 );
366 let w = imported_op_warnings(&s).unwrap();
367 assert!(
368 w.iter()
369 .any(|x| x.contains("auto-close") && x.contains("warning")),
370 "the op-word must surface as a warning; got: {w:?}"
371 );
372 }
373
374 #[test]
375 fn verify_should_still_violate_on_an_op_word_when_provenance_is_agent_proposed() {
376 let (_repo, _s, v) = op_tick_with_provenance(Some("agent-proposed"));
378
379 assert!(
381 v.iter().any(|x| x.contains("R5 forbidden op")),
382 "agent-proposed must keep the op-arm hard; got: {v:?}"
383 );
384 }
385
386 #[test]
387 fn verify_should_still_violate_on_an_op_word_when_provenance_is_human_now() {
388 let (_repo, _s, v) = op_tick_with_provenance(None);
390
391 assert!(
393 v.iter().any(|x| x.contains("R5 forbidden op")),
394 "human-now must keep the op-arm hard; got: {v:?}"
395 );
396 }
397
398 #[test]
399 fn verify_should_keep_empty_blame_and_c_d_no_test_hard_even_when_imported() {
400 use crate::tick::{Check, Liveness};
402 let repo = tmp();
403 let s = Store::at(&repo);
404 s.init().unwrap();
405 let mut t = tick("");
406 t.provenance = Some("imported".into());
407 t.jurisdiction = Some("C".into());
408 t.blame = "".into();
409 t.grounds[0].check = Some(Check::Test {
410 reference: "pytest x".into(),
411 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
412 counter_test: Some("pytest x::flips".into()),
413 liveness: Liveness {
414 platforms: vec!["linux-ci".into()],
415 triggered_by: vec!["f".into()],
416 surfaces: vec!["s".into()],
417 },
418 });
419 t.id = compute_id(&t);
420 s.write_tick(&t).unwrap();
421
422 let v = verify(&s).unwrap();
424
425 assert!(
427 v.iter().any(|x| x.contains("empty blame")),
428 "empty-blame stays hard for imported; got: {v:?}"
429 );
430 assert!(
431 v.iter()
432 .any(|x| x.to_lowercase().contains("jurisdiction")
433 && x.to_lowercase().contains("test")),
434 "C/D-no-test stays hard for imported; got: {v:?}"
435 );
436 }
437
438 #[test]
439 fn verify_should_flag_an_empty_blame_when_a_tick_blame_is_blanked_on_disk() {
440 let repo = tmp();
442 let s = Store::at(&repo);
443 s.init().unwrap();
444 let t = tick("");
445 s.write_tick(&t).unwrap();
446 let p = s.ticks_dir().join(&t.id);
447 let text = std::fs::read_to_string(&p)
448 .unwrap()
449 .replace("\"Wang Yu\"", "\"\"");
450 std::fs::write(&p, text).unwrap();
451
452 let v = verify(&s).unwrap();
454
455 assert!(v.iter().any(|x| x.to_lowercase().contains("blame")));
457 }
458
459 #[test]
460 fn unknown_key_warnings_should_warn_but_not_violate_when_a_tick_carries_a_tolerated_unknown_key(
461 ) {
462 let repo = tmp();
465 let s = Store::at(&repo);
466 s.init().unwrap();
467 let t = tick("");
468 s.write_tick(&t).unwrap();
469 let p = s.ticks_dir().join(&t.id);
470 let text = std::fs::read_to_string(&p)
471 .unwrap()
472 .replace("\"blame\"", "\"future_field\": \"x\",\n \"blame\"");
473 std::fs::write(&p, text).unwrap();
474
475 let v = verify(&s).unwrap();
477 let w = unknown_key_warnings(&s).unwrap();
478
479 assert!(
481 v.is_empty(),
482 "a tolerated unknown key must not violate: {v:?}"
483 );
484 assert!(
485 w.iter()
486 .any(|x| x.contains("future_field") && x.contains("warning")),
487 "expected a warning naming the tolerated key; got: {w:?}"
488 );
489 }
490}