1use crate::canonical::compute_id;
2use crate::store::Store;
3use crate::tick::{Check, Ground, Liveness, Tick};
4use crate::verify::verify;
5use std::path::Path;
6use std::process::ExitCode;
7
8pub fn correct(repo: &Path, a: crate::correct::CorrectArgs) -> ExitCode {
10 match crate::correct::run(repo, a) {
11 Ok(t) => {
12 println!("corrected {} ({} ground(s))", t.id, t.grounds.len());
13 ExitCode::SUCCESS
14 }
15 Err(e) => {
16 eprintln!("error: {e}");
17 ExitCode::FAILURE
18 }
19 }
20}
21
22fn decision_identity(t: &Tick) -> String {
29 let mut v = crate::canonical::hashed_value(t);
30 if let serde_json::Value::Object(m) = &mut v {
31 m.remove("parent_id");
32 }
33 v.to_string()
34}
35
36fn current_decisions(mut ticks: Vec<(String, Tick)>) -> Vec<(String, Tick)> {
40 ticks.sort_by(|a, b| b.1.held_since.cmp(&a.1.held_since).then(b.0.cmp(&a.0)));
42 let mut seen = std::collections::HashSet::new();
43 ticks
44 .into_iter()
45 .filter(|(_, t)| seen.insert(decision_identity(t)))
46 .collect()
47}
48
49fn render_source_ref(v: &serde_json::Value) -> String {
54 v.as_str()
55 .map(String::from)
56 .unwrap_or_else(|| v.to_string())
57}
58
59fn triggered_since(
63 repo: &std::path::Path,
64 ground: &crate::tick::Ground,
65 receipts: &[crate::receipt::Receipt],
66) -> bool {
67 use crate::tick::Check;
68 let triggered_by = match &ground.check {
69 Some(Check::Test { liveness, .. }) => &liveness.triggered_by,
70 _ => return false,
71 };
72 let latest = receipts.iter().max_by(|a, b| a.ran_at.cmp(&b.ran_at));
73 match latest {
74 Some(r) => crate::liveness::changed_since(repo, &r.commit, triggered_by).unwrap_or(false),
75 None => false,
76 }
77}
78
79pub fn init(repo: &Path) -> ExitCode {
80 let store = Store::at(repo);
81 match store.init() {
82 Ok(true) => {
83 println!("created .evolving/ (content-addressed chain + results cache)");
84 ExitCode::SUCCESS
85 }
86 Ok(false) => {
87 println!(".evolving/ already exists (no-op)");
88 ExitCode::SUCCESS
89 }
90 Err(e) => {
91 eprintln!("error: could not create .evolving/: {e}");
92 ExitCode::FAILURE
93 }
94 }
95}
96pub fn show(repo: &Path, id: &str) -> ExitCode {
97 let store = Store::at(repo);
98 let path = store.ticks_dir().join(id);
99 if !path.is_file() {
100 eprintln!("error: no tick with id {id}");
101 return ExitCode::FAILURE;
102 }
103 match std::fs::read_to_string(&path) {
104 Ok(text) => {
105 println!("{text}");
107 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
109 if let Some(a) = v.get("authority").and_then(|x| x.as_str()) {
110 println!("authority: {a}");
111 }
112 if let Some(j) = v.get("jurisdiction").and_then(|x| x.as_str()) {
113 println!("jurisdiction: {j}");
114 }
115 if let Some(r) = v.get("source_ref") {
116 println!("source_ref: {}", render_source_ref(r));
117 }
118 }
119 ExitCode::SUCCESS
120 }
121 Err(e) => {
122 eprintln!("error: reading {id}: {e}");
123 ExitCode::FAILURE
124 }
125 }
126}
127pub fn decide(repo: &Path, decision: Option<&str>, args: &[String]) -> ExitCode {
128 let (decision, args): (Option<&str>, Vec<String>) = match decision {
133 Some(d) if d.starts_with('-') => {
134 let mut v = vec![d.to_string()];
135 v.extend_from_slice(args);
136 (None, v)
137 }
138 other => (other, args.to_vec()),
139 };
140 match crate::capture::run(repo, decision, &args) {
141 Ok(t) => {
142 crate::events::append(&Store::at(repo), "decide", Some(&t), None, None);
143 println!("recorded {} ({} ground(s))", t.id, t.grounds.len());
144 ExitCode::SUCCESS
145 }
146 Err(e) => {
147 eprintln!("error: {e}");
148 ExitCode::FAILURE
149 }
150 }
151}
152
153pub fn guard(repo: &Path, a: crate::guard::GuardArgs) -> ExitCode {
154 match crate::guard::run(repo, a) {
155 Ok(t) => {
156 crate::events::append(&Store::at(repo), "guard", Some(&t), None, None);
157 println!("bound; wrote child {}", t.id);
158 ExitCode::SUCCESS
159 }
160 Err(e) => {
161 eprintln!("error: {e}");
162 ExitCode::FAILURE
163 }
164 }
165}
166
167pub fn verify_cmd(repo: &Path, self_test: bool) -> ExitCode {
168 if self_test {
169 return self_test_golden();
170 }
171 let store = Store::at(repo);
172 for w in crate::verify::unknown_key_warnings(&store).unwrap_or_default() {
175 eprintln!("{w}");
176 }
177 for w in crate::verify::imported_op_warnings(&store).unwrap_or_default() {
180 eprintln!("{w}");
181 }
182 match verify(&store) {
183 Ok(v) if v.is_empty() => {
184 println!("✓ chain intact: every id == hash(payload), lineage forward-only");
185 println!("✓ every tick validates against the closed schema (R1) and check shape (R2)");
186 ExitCode::SUCCESS
187 }
188 Ok(v) => {
189 for line in &v {
190 println!("✗ {line}");
191 }
192 eprintln!("{} violation(s)", v.len());
193 ExitCode::FAILURE
194 }
195 Err(e) => {
196 eprintln!("error: reading store: {e}");
197 ExitCode::FAILURE
198 }
199 }
200}
201
202fn latest_ran_at(receipts: &[crate::receipt::Receipt]) -> Option<String> {
205 receipts.iter().map(|r| r.ran_at.clone()).max()
206}
207
208fn verdict_rank(v: &crate::verdict::Verdict) -> u8 {
215 use crate::verdict::Verdict;
216 match v {
217 Verdict::Red | Verdict::GrayRed => 6,
218 Verdict::SilentlyUnbound => 5,
220 Verdict::Stale { .. } => 4,
221 Verdict::NotRun { .. } => 3,
222 Verdict::Unproven => 2,
223 Verdict::Memo => 1,
224 Verdict::Green | Verdict::Exempt | Verdict::NotApplicable => 0,
225 }
226}
227
228fn roll_up_check(verdicts: &[&crate::verdict::Verdict]) -> Option<(String, Option<String>)> {
235 use crate::verdict::Verdict;
236 let mut worst: Option<(u8, &Verdict)> = None;
237 let mut stale: Option<&Verdict> = None;
238 for &v in verdicts {
239 let rank = verdict_rank(v);
240 if worst.map_or(true, |(r, _)| rank > r) {
241 worst = Some((rank, v));
242 }
243 if stale.is_none() && matches!(v, Verdict::Stale { .. }) {
244 stale = Some(v);
245 }
246 }
247 worst.map(|(rank, v)| {
248 let masked = if rank > 4 {
249 stale.map(|s| s.event_label())
250 } else {
251 None
252 };
253 (v.event_label(), masked)
254 })
255}
256
257fn live_ctx(
261 store: &Store,
262 staleness_days: u64,
263 live_origin_sha: Option<String>,
264 attest: Option<Vec<String>>,
265) -> crate::verdict::Ctx {
266 crate::verdict::Ctx {
267 live_origin_sha,
268 selected: crate::selected::read(store).unwrap_or(None),
269 now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
270 staleness_secs: staleness_days as i64 * 86_400,
271 attest,
272 }
273}
274
275pub fn check(
276 repo: &Path,
277 exit_on_red: bool,
278 run: bool,
279 platform: &str,
280 offline: bool,
281 attest: Vec<String>,
282) -> ExitCode {
283 use crate::verdict::{verdict_for, Verdict};
284 let store = Store::at(repo);
285 if !store.exists() {
286 eprintln!("error: no .evolving/ store here — run `ev init` first");
287 return ExitCode::FAILURE;
288 }
289 let files = match store.read_all() {
290 Ok(f) => f,
291 Err(e) => {
292 eprintln!("error: reading store: {e}");
293 return ExitCode::FAILURE;
294 }
295 };
296 let config = crate::config::read(&store);
297
298 if run {
301 for (_filename, raw) in &files {
302 let t = match crate::tick::from_value(raw) {
303 Ok(t) => t,
304 Err(_) => continue,
305 };
306 if t.status != "live" {
307 continue;
308 }
309 for g in &t.grounds {
310 if let Some(Check::Test {
311 reference,
312 counter_test,
313 liveness,
314 ..
315 }) = &g.check
316 {
317 if liveness.platforms.iter().any(|p| p == platform) {
318 match crate::runner::run_check(
320 repo,
321 reference,
322 platform,
323 config.green_exit_code,
324 ) {
325 Ok(mut rc) => {
326 if let Some(counter_test) = counter_test {
330 if let Ok(ct) = crate::runner::run_check(
331 repo,
332 counter_test,
333 platform,
334 config.green_exit_code,
335 ) {
336 rc.falsifiable = Some(rc.result != ct.result);
337 }
338 }
339 if let Err(e) = crate::receipt::append(&store, &rc) {
340 eprintln!(
341 "warning: could not write receipt for {reference:?}: {e}"
342 );
343 }
344 }
345 Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
346 }
347 }
348 }
349 }
350 }
351 }
352
353 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
354 let attest = if attest.is_empty() {
355 None
356 } else {
357 Some(attest)
358 };
359 let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
360 let mut rows: Vec<String> = Vec::new();
361 let mut any_not_green = false;
362 let mut total_test_bindings = 0usize;
366 let mut harvested_unproven = 0usize;
367
368 for (filename, raw) in &files {
369 let t = match crate::tick::from_value(raw) {
370 Ok(t) => t,
371 Err(_) => continue, };
373 if t.status != "live" {
374 continue;
375 }
376 let mut verdicts = Vec::with_capacity(t.grounds.len());
377 for g in &t.grounds {
378 let receipts = match &g.check {
380 Some(Check::Test { reference, .. }) => {
381 crate::receipt::read_for(&store, reference).unwrap_or_default()
382 }
383 _ => Vec::new(),
384 };
385 let ts = triggered_since(repo, g, &receipts);
387 let mut v = verdict_for(g, &receipts, &ctx, ts);
388 if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
393 && !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt)
394 {
395 v = Verdict::Memo;
396 }
397 if !matches!(
398 v,
399 Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
400 ) {
401 any_not_green = true;
402 }
403 if let Some(Check::Test { counter_test, .. }) = &g.check {
405 total_test_bindings += 1;
406 let harvested = counter_test.is_none();
407 let mut detail = match &v {
408 Verdict::NotRun { missing_platforms } => {
409 format!("missing: {}", missing_platforms.join(", "))
410 }
411 Verdict::Stale { reason, .. } => reason.clone(),
412 _ => latest_ran_at(&receipts)
413 .map(|ts| format!("ran {ts}"))
414 .unwrap_or_else(|| "no receipt".into()),
415 };
416 if harvested {
420 harvested_unproven += 1;
421 detail = format!("harvested — falsifiability not proven; {detail}");
422 crate::events::append(
423 &store,
424 "harvested",
425 Some(&t),
426 Some(&v.event_label()),
427 None,
428 );
429 }
430 rows.push(format!(
431 "{}\t{filename}\t{:?}\t({detail})",
432 v.label(),
433 g.claim
434 ));
435 }
436 verdicts.push((g, v));
437 }
438 let test_verdicts: Vec<&Verdict> = verdicts
441 .iter()
442 .filter(|(g, _)| matches!(g.check, Some(Check::Test { .. })))
443 .map(|(_, v)| v)
444 .collect();
445 if let Some((label, masked_stale)) = roll_up_check(&test_verdicts) {
446 crate::events::append(
447 &store,
448 "check",
449 Some(&t),
450 Some(&label),
451 masked_stale.as_deref(),
452 );
453 }
454 let _ = crate::state::write_state(
456 &store,
457 &t.id,
458 &verdicts,
459 &config.staleness_ref,
460 ctx.live_origin_sha.as_deref(),
461 );
462 }
463
464 if rows.is_empty() {
465 println!("no test-bound grounds to check");
466 } else {
467 for r in &rows {
468 println!("{r}");
469 }
470 if harvested_unproven > 0 {
473 println!(
474 "harvested-unproven: {harvested_unproven} of {total_test_bindings} test bindings have no counter-test (run ev guard to add one)"
475 );
476 }
477 if !run {
478 println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
482 }
483 }
484 if exit_on_red && any_not_green {
485 return ExitCode::FAILURE;
486 }
487 ExitCode::SUCCESS
488}
489
490pub struct MigrateArgs {
492 pub sources: Vec<String>,
493 pub dry_run: bool,
494 pub reconcile: bool,
495 pub against: Option<String>,
496 pub blame: Option<String>,
497 pub bind_check: Option<String>,
498 pub platforms: Vec<String>,
499 pub triggered_by: Vec<String>,
500 pub surfaces: Vec<String>,
501 pub verified_at_sha: Option<String>,
502 pub jurisdiction_map: Option<String>,
503}
504
505fn parse_jurisdiction_map(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
511 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
512 let mut map = std::collections::HashMap::new();
513 for line in text.lines() {
514 let l = line.trim();
515 if l.is_empty() || l.starts_with('#') {
516 continue;
517 }
518 let mut tokens = l.split_whitespace();
519 match (tokens.next(), tokens.next(), tokens.next()) {
520 (Some(key), Some(bucket), None) => {
521 crate::tick::validate_jurisdiction(bucket)
522 .map_err(|e| format!("jurisdiction-map line {l:?}: {e}"))?;
523 map.insert(key.to_string(), bucket.to_string());
524 }
525 _ => {
526 return Err(format!(
527 "jurisdiction-map line {l:?}: expected `<source_key> <bucket>`"
528 ))
529 }
530 }
531 }
532 Ok(map)
533}
534
535fn extract_source(spec: &str) -> Result<Vec<crate::migrate::MigrationRecord>, String> {
539 let (kind, path) = spec
540 .split_once(':')
541 .ok_or_else(|| format!("--source expects <kind>:<path>, got {spec:?}"))?;
542 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
543 let recs = match kind {
544 "canonical" => crate::migrate::canonical_records(&text)?,
547 "gitlog" => crate::migrate::extract_gitlog(&text),
548 "to-human" => crate::migrate::extract_to_human(&text),
549 "decisions-immutable" => crate::migrate::extract_decisions_immutable(&text),
550 "escalation" => crate::migrate::extract_escalation(&text),
551 other => {
552 return Err(format!(
553 "unknown source kind {other:?} (expected canonical | gitlog | to-human | decisions-immutable | escalation)"
554 ))
555 }
556 };
557 Ok(recs)
558}
559
560pub fn migrate(repo: &Path, a: MigrateArgs) -> ExitCode {
561 if let Some(selector) = &a.bind_check {
563 let sha = match crate::capture::resolve_sha(repo, &a.verified_at_sha) {
564 Ok(s) => s,
565 Err(e) => {
566 eprintln!("error: {e}");
567 return ExitCode::FAILURE;
568 }
569 };
570 match crate::migrate::bind_check(
571 selector.clone(),
572 sha,
573 a.platforms.clone(),
574 a.triggered_by.clone(),
575 a.surfaces.clone(),
576 ) {
577 Ok(Check::Test {
578 reference,
579 liveness,
580 ..
581 }) => {
582 println!(
583 "harvested check (falsifiability not proven; no counter-test): {reference:?} on [{}] triggered-by [{}] surface [{}]",
584 liveness.platforms.join(", "),
585 liveness.triggered_by.join(", "),
586 liveness.surfaces.join(", ")
587 );
588 return ExitCode::SUCCESS;
589 }
590 Ok(_) => unreachable!("bind_check yields a Test check"),
591 Err(e) => {
592 eprintln!("error: {e}");
593 return ExitCode::FAILURE;
594 }
595 }
596 }
597
598 if a.reconcile {
600 let against = match &a.against {
601 Some(s) => s,
602 None => {
603 eprintln!("error: --reconcile requires --against <kind>:<path>");
604 return ExitCode::FAILURE;
605 }
606 };
607 let recs = match extract_source(against) {
608 Ok(r) => r,
609 Err(e) => {
610 eprintln!("error: {e}");
611 return ExitCode::FAILURE;
612 }
613 };
614 match crate::migrate::reconcile(repo, &recs) {
615 Ok(rep) => {
616 println!(
617 "reconcile: in-both {}, source-only {} (the capture gap), store-only {}, un-keyable {}",
618 rep.in_both, rep.source_only, rep.store_only, rep.un_keyable
619 );
620 return ExitCode::SUCCESS;
621 }
622 Err(e) => {
623 eprintln!("error: {e}");
624 return ExitCode::FAILURE;
625 }
626 }
627 }
628
629 if a.sources.is_empty() {
631 eprintln!("error: ev migrate needs at least one --source <kind>:<path> (or --reconcile / --bind-check)");
632 return ExitCode::FAILURE;
633 }
634 let mut records = Vec::new();
635 for spec in &a.sources {
636 match extract_source(spec) {
637 Ok(mut r) => records.append(&mut r),
638 Err(e) => {
639 eprintln!("error: {e}");
640 return ExitCode::FAILURE;
641 }
642 }
643 }
644 let jurisdiction_map = match &a.jurisdiction_map {
646 Some(path) => match parse_jurisdiction_map(path) {
647 Ok(m) => m,
648 Err(e) => {
649 eprintln!("error: {e}");
650 return ExitCode::FAILURE;
651 }
652 },
653 None => std::collections::HashMap::new(),
654 };
655 match crate::migrate::backfill(
656 repo,
657 records,
658 a.blame.as_deref(),
659 &jurisdiction_map,
660 a.dry_run,
661 ) {
662 Ok(s) => {
663 if !a.dry_run {
664 crate::events::append(&Store::at(repo), "migrate", None, None, None);
665 }
666 println!(
667 "{}imported {}, skipped {}, re-linked {}, {} source-only gap(s){}",
668 if a.dry_run { "(dry-run) " } else { "" },
669 s.imported,
670 s.skipped,
671 s.relinked,
672 s.source_only_gaps,
673 if s.discrepancies > 0 {
674 format!(", {} discrepancy(ies) — see above", s.discrepancies)
675 } else {
676 String::new()
677 }
678 );
679 ExitCode::SUCCESS
680 }
681 Err(e) => {
682 eprintln!("error: {e}");
683 ExitCode::FAILURE
684 }
685 }
686}
687
688pub fn why(repo: &Path, selector: &str) -> ExitCode {
689 let store = Store::at(repo);
690 if !store.exists() {
691 eprintln!("error: no .evolving/ store here — run `ev init` first");
692 return ExitCode::FAILURE;
693 }
694 let files = match store.read_all() {
695 Ok(f) => f,
696 Err(e) => {
697 eprintln!("error: reading store: {e}");
698 return ExitCode::FAILURE;
699 }
700 };
701 let mut found = false;
702 for (filename, raw) in &files {
703 let t = match crate::tick::from_value(raw) {
704 Ok(t) => t,
705 Err(_) => continue,
706 };
707 if t.status != "live" {
708 continue;
709 }
710 for g in &t.grounds {
711 if let Some(Check::Test { reference, .. }) = &g.check {
712 if reference.as_str() == selector {
713 found = true;
714 println!(
715 "{filename}\t{:?}\tguards: {:?} ({})",
716 t.decision, g.claim, g.supports
717 );
718 }
719 }
720 }
721 }
722 if !found {
723 eprintln!("{selector:?} guards nothing");
724 return ExitCode::FAILURE;
725 }
726 ExitCode::SUCCESS
727}
728
729pub fn list(repo: &Path) -> ExitCode {
731 let store = Store::at(repo);
732 if !store.exists() {
733 eprintln!("error: no .evolving/ store here — run `ev init` first");
734 return ExitCode::FAILURE;
735 }
736 let files = match store.read_all() {
737 Ok(f) => f,
738 Err(e) => {
739 eprintln!("error: reading store: {e}");
740 return ExitCode::FAILURE;
741 }
742 };
743 let mut parsed: Vec<(String, Tick)> = Vec::new();
749 let mut rows: Vec<String> = Vec::new();
750 for (name, raw) in &files {
751 match crate::tick::from_value(raw) {
752 Ok(t) => parsed.push((name.clone(), t)),
753 Err(_) => rows.push(format!("{name}\t?\t\"<unparseable>\"")),
754 }
755 }
756 for (name, t) in current_decisions(parsed) {
757 let mut l = format!("{name}\t{}\t{:?}", t.status, t.decision);
758 if let Some(a) = &t.authority {
759 l.push_str(&format!("\tauthority={a}"));
760 }
761 if let Some(j) = &t.jurisdiction {
762 l.push_str(&format!("\tjurisdiction={j}"));
763 }
764 if let Some(r) = &t.source_ref {
765 l.push_str(&format!("\tsource_ref={}", render_source_ref(r)));
766 }
767 rows.push(l);
768 }
769 rows.sort();
770 if rows.is_empty() {
771 println!("no decisions yet");
772 return ExitCode::SUCCESS;
773 }
774 for line in &rows {
775 println!("{line}");
776 }
777 ExitCode::SUCCESS
778}
779
780fn load_bearing(t: &Tick) -> bool {
784 t.grounds
785 .iter()
786 .any(|g| g.supports.starts_with("rejected:"))
787}
788
789pub fn brief(repo: &Path, limit: Option<usize>) -> ExitCode {
796 let store = Store::at(repo);
797 if !store.exists() {
798 eprintln!("error: no .evolving/ store here — run `ev init` first");
799 return ExitCode::FAILURE;
800 }
801 let files = match store.read_all() {
802 Ok(f) => f,
803 Err(e) => {
804 eprintln!("error: reading store: {e}");
805 return ExitCode::FAILURE;
806 }
807 };
808 let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
810 let all: Vec<(String, Tick)> = files
813 .iter()
814 .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
815 .collect();
816 let mut kept: Vec<(String, Tick)> = current_decisions(all)
817 .into_iter()
818 .filter(|(_, t)| t.status == "live" && t.authority.as_deref() == Some("user-ruled"))
819 .collect();
820 let lb = load_bearing;
821 kept.sort_by(|a, b| {
824 lb(&b.1)
825 .cmp(&lb(&a.1))
826 .then(b.1.held_since.cmp(&a.1.held_since))
827 .then(b.0.cmp(&a.0))
828 });
829 if kept.is_empty() {
830 println!("no user-ruled decisions");
831 return ExitCode::SUCCESS;
832 }
833 let total = kept.len();
834 let n = if limit == 0 { total } else { limit.min(total) };
836 let dropped_lb = kept[n..].iter().filter(|(_, t)| lb(t)).count();
838 kept.truncate(n);
839 for (_id, t) in &kept {
840 println!("{} [user-ruled]", t.decision);
841 for g in &t.grounds {
842 if let Some(option) = g.supports.strip_prefix("rejected:") {
843 println!(" rejected {option}: {}", g.claim);
844 }
845 }
846 }
847 if total > n {
848 let dropped = total - n;
849 let lb_clause = if dropped_lb > 0 {
850 format!(", {dropped_lb} with rejected roads")
851 } else {
852 String::new()
853 };
854 println!("… {dropped} more user-ruled decision(s){lb_clause} — `ev list` for all");
855 }
856 ExitCode::SUCCESS
857}
858
859pub fn log(repo: &Path) -> ExitCode {
861 let store = Store::at(repo);
862 if !store.exists() {
863 eprintln!("error: no .evolving/ store here — run `ev init` first");
864 return ExitCode::FAILURE;
865 }
866 let mut id = match store.read_head() {
867 Ok(h) => h,
868 Err(e) => {
869 eprintln!("error: reading HEAD: {e}");
870 return ExitCode::FAILURE;
871 }
872 };
873 if id.is_empty() {
874 println!("no decisions yet");
875 return ExitCode::SUCCESS;
876 }
877 let mut seen = std::collections::HashSet::new();
878 while !id.is_empty() {
879 if !seen.insert(id.clone()) {
880 break; }
882 match store.read_tick(&id) {
883 Ok(Some(t)) => {
884 println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
885 id = t.parent_id;
886 }
887 Ok(None) => {
888 eprintln!("warning: {id} not found (broken lineage)");
889 break;
890 }
891 Err(e) => {
892 eprintln!("error: reading {id}: {e}");
893 return ExitCode::FAILURE;
894 }
895 }
896 }
897 ExitCode::SUCCESS
898}
899
900pub fn reopen(repo: &Path, id: &str) -> ExitCode {
901 let store = Store::at(repo);
902 let tick = match store.read_tick(id) {
903 Ok(Some(t)) => t,
904 Ok(None) => {
905 eprintln!("error: no tick with id {id}");
906 return ExitCode::FAILURE;
907 }
908 Err(e) => {
909 eprintln!("error: reading {id}: {e}");
910 return ExitCode::FAILURE;
911 }
912 };
913 let config = crate::config::read(&store);
914 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
915 let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
916
917 crate::events::append(&store, "reopen", Some(&tick), None, None);
918 println!("decision {}: {:?}", tick.id, tick.decision);
919 if !tick.observe.is_empty() {
920 println!("observe: {:?}", tick.observe);
921 }
922 if let Some(a) = &tick.authority {
923 println!("authority: {a}");
924 }
925 if let Some(j) = &tick.jurisdiction {
926 println!("jurisdiction: {j}");
927 }
928 if let Some(r) = &tick.source_ref {
929 println!("source_ref: {}", render_source_ref(r));
930 }
931 for g in &tick.grounds {
932 match &g.check {
933 Some(Check::Test {
934 reference,
935 verified_at_sha,
936 ..
937 }) => {
938 let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
939 let ts = triggered_since(repo, g, &receipts);
940 let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
941 let now = v.label();
942 let short = &verified_at_sha[..verified_at_sha.len().min(8)];
943 println!(
944 " [{}] {:?} — test {:?} frozen@{short} now: {now}",
945 g.supports, g.claim, reference
946 );
947 }
948 Some(Check::Person { reference }) => {
949 println!(" [{}] {:?} — person {:?}", g.supports, g.claim, reference);
950 }
951 None => {
952 println!(" [{}] {:?}", g.supports, g.claim);
953 }
954 }
955 }
956 ExitCode::SUCCESS
957}
958
959fn self_test_golden() -> ExitCode {
961 let genesis = Tick {
962 id: String::new(),
963 parent_id: "".into(),
964 observe: "evaluating retrieval backend".into(),
965 decision: "freeze the retrieval schema for v2".into(),
966 grounds: vec![
967 Ground {
968 claim: "team still wants a frozen schema".into(),
969 supports: "chosen".into(),
970 check: Some(Check::Person {
971 reference: "Q3 infra review".into(),
972 }),
973 },
974 Ground {
975 claim: "pgvector would lock our schema".into(),
976 supports: "rejected:pgvector".into(),
977 check: None,
978 },
979 ],
980 status: "live".into(),
981 held_since: "".into(),
982 blame: "Wang Yu".into(),
983 authority: None,
984 jurisdiction: None,
985 source_ref: None,
986 provenance: None,
987 };
988 let case1 = Tick {
989 id: String::new(),
990 parent_id: "7b21f0a4c8de".into(),
991 observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
992 decision: "restore-safety counter DB-backed; reject Redis".into(),
993 grounds: vec![
994 Ground {
995 claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
996 supports: "chosen".into(),
997 check: Some(Check::Test {
998 reference: "pytest tests/test_redis_absent.py".into(),
999 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
1000 counter_test: Some(
1001 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
1002 ),
1003 liveness: Liveness {
1004 platforms: vec!["linux-ci".into()],
1005 triggered_by: vec!["pyproject.toml".into()],
1006 surfaces: vec!["pyproject-deps".into()],
1007 },
1008 }),
1009 },
1010 Ground {
1011 claim: "team still wants 0-Redis posture".into(),
1012 supports: "chosen".into(),
1013 check: Some(Check::Person {
1014 reference: "Q3 infra review".into(),
1015 }),
1016 },
1017 Ground {
1018 claim: "Redis would add a new infra dependency".into(),
1019 supports: "rejected:Redis".into(),
1020 check: None,
1021 },
1022 ],
1023 status: "live".into(),
1024 held_since: "".into(),
1025 blame: "Wang Yu".into(),
1026 authority: None,
1027 jurisdiction: None,
1028 source_ref: None,
1029 provenance: None,
1030 };
1031 let mut harvested = case1.clone();
1034 if let Some(Check::Test { counter_test, .. }) = &mut harvested.grounds[0].check {
1035 *counter_test = None;
1036 }
1037 let mut ok = true;
1038 for (name, t, want) in [
1039 ("genesis", &genesis, "e2b337f53a1f"),
1040 ("case1", &case1, "638c47b0c9dd"),
1041 ("harvested", &harvested, "0cf784b51331"),
1042 ] {
1043 let got = compute_id(t);
1044 let pass = got == want;
1045 ok &= pass;
1046 println!(
1047 "{} {name}: {got} (want {want})",
1048 if pass { "✓" } else { "✗" }
1049 );
1050 }
1051 if ok {
1052 ExitCode::SUCCESS
1053 } else {
1054 ExitCode::FAILURE
1055 }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::roll_up_check;
1061 use crate::verdict::{StaleKind, Verdict};
1062
1063 fn stale_sha() -> Verdict {
1064 Verdict::Stale {
1065 kind: StaleKind::Sha,
1066 reason: String::new(),
1067 }
1068 }
1069
1070 #[test]
1071 fn roll_up_check_should_emit_nothing_when_there_is_no_test_bound_ground() {
1072 assert_eq!(roll_up_check(&[]), None);
1074 }
1075
1076 #[test]
1077 fn roll_up_check_should_carry_the_worst_verdict_red_over_green() {
1078 let (g, r) = (Verdict::Green, Verdict::Red);
1080 assert_eq!(roll_up_check(&[&g, &r]), Some(("red".to_string(), None)));
1081 }
1082
1083 #[test]
1084 fn roll_up_check_should_let_a_gating_silently_unbound_outrank_a_co_occurring_green() {
1085 let (su, g) = (Verdict::SilentlyUnbound, Verdict::Green);
1088 assert_eq!(
1089 roll_up_check(&[&su, &g]),
1090 Some(("silently-unbound".to_string(), None))
1091 );
1092 assert_eq!(
1093 roll_up_check(&[&g, &su]),
1094 Some(("silently-unbound".to_string(), None))
1095 );
1096 }
1097
1098 #[test]
1099 fn roll_up_check_should_carry_the_stale_sub_kind_when_stale_is_the_worst() {
1100 let (s, nr) = (
1102 stale_sha(),
1103 Verdict::NotRun {
1104 missing_platforms: vec!["p".to_string()],
1105 },
1106 );
1107 assert_eq!(
1108 roll_up_check(&[&s, &nr]),
1109 Some(("stale:sha".to_string(), None))
1110 );
1111 }
1112
1113 #[test]
1114 fn roll_up_check_should_surface_a_stale_masked_behind_a_red() {
1115 let (r, s) = (Verdict::Red, stale_sha());
1118 assert_eq!(
1119 roll_up_check(&[&r, &s]),
1120 Some(("red".to_string(), Some("stale:sha".to_string())))
1121 );
1122 }
1123
1124 #[test]
1125 fn roll_up_check_should_emit_green_when_every_ground_is_green() {
1126 let (a, b) = (Verdict::Green, Verdict::Green);
1127 assert_eq!(roll_up_check(&[&a, &b]), Some(("green".to_string(), None)));
1128 }
1129}