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