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 if let Ok(ct) = crate::runner::run_check(
332 repo,
333 counter_test,
334 platform,
335 config.green_exit_code,
336 ) {
337 rc.falsifiable = Some(rc.result != ct.result);
338 }
339 }
340 if let Err(e) = crate::receipt::append(&store, &rc) {
341 eprintln!(
342 "warning: could not write receipt for {reference:?}: {e}"
343 );
344 }
345 }
346 Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
347 }
348 }
349 }
350 }
351 }
352 }
353
354 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
355 let attest = if attest.is_empty() {
356 None
357 } else {
358 Some(attest)
359 };
360 let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
361 let mut rows: Vec<String> = Vec::new();
362 let mut any_not_green = false;
363 let mut total_test_bindings = 0usize;
367 let mut harvested_unproven = 0usize;
368
369 for (filename, raw) in &files {
370 let t = match crate::tick::from_value(raw) {
371 Ok(t) => t,
372 Err(_) => continue, };
374 if t.status != "live" {
375 continue;
376 }
377 let mut verdicts = Vec::with_capacity(t.grounds.len());
378 for g in &t.grounds {
379 let receipts = match &g.check {
381 Some(Check::Test { reference, .. }) => {
382 crate::receipt::read_for(&store, reference).unwrap_or_default()
383 }
384 _ => Vec::new(),
385 };
386 let ts = triggered_since(repo, g, &receipts);
388 let mut v = verdict_for(g, &receipts, &ctx, ts);
389 if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
394 && !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt)
395 {
396 v = Verdict::Memo;
397 }
398 if t.provenance.as_deref() == Some("agent-proposed")
406 && !matches!(
407 v,
408 Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
409 )
410 {
411 v = Verdict::Memo;
412 }
413 if !matches!(
414 v,
415 Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
416 ) {
417 any_not_green = true;
418 }
419 if let Some(Check::Test { counter_test, .. }) = &g.check {
421 total_test_bindings += 1;
422 let harvested = counter_test.is_none();
423 let mut detail = match &v {
424 Verdict::NotRun { missing_platforms } => {
425 format!("missing: {}", missing_platforms.join(", "))
426 }
427 Verdict::Stale { reason, .. } => reason.clone(),
428 _ => latest_ran_at(&receipts)
429 .map(|ts| format!("ran {ts}"))
430 .unwrap_or_else(|| "no receipt".into()),
431 };
432 if harvested {
436 harvested_unproven += 1;
437 detail = format!("harvested — falsifiability not proven; {detail}");
438 crate::events::append(
439 &store,
440 "harvested",
441 Some(&t),
442 Some(&v.event_label()),
443 None,
444 );
445 }
446 rows.push(format!(
447 "{}\t{filename}\t{:?}\t({detail})",
448 v.label(),
449 g.claim
450 ));
451 }
452 verdicts.push((g, v));
453 }
454 let test_verdicts: Vec<&Verdict> = verdicts
457 .iter()
458 .filter(|(g, _)| matches!(g.check, Some(Check::Test { .. })))
459 .map(|(_, v)| v)
460 .collect();
461 if let Some((label, masked_stale)) = roll_up_check(&test_verdicts) {
462 crate::events::append(
463 &store,
464 "check",
465 Some(&t),
466 Some(&label),
467 masked_stale.as_deref(),
468 );
469 }
470 let _ = crate::state::write_state(
472 &store,
473 &t.id,
474 &verdicts,
475 &config.staleness_ref,
476 ctx.live_origin_sha.as_deref(),
477 );
478 }
479
480 if rows.is_empty() {
481 println!("no test-bound grounds to check");
482 } else {
483 for r in &rows {
484 println!("{r}");
485 }
486 if harvested_unproven > 0 {
489 println!(
490 "harvested-unproven: {harvested_unproven} of {total_test_bindings} test bindings have no counter-test (run ev guard to add one)"
491 );
492 }
493 if !run {
494 println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
498 }
499 }
500 if exit_on_red && any_not_green {
501 return ExitCode::FAILURE;
502 }
503 ExitCode::SUCCESS
504}
505
506pub struct MigrateArgs {
508 pub sources: Vec<String>,
509 pub dry_run: bool,
510 pub reconcile: bool,
511 pub against: Option<String>,
512 pub blame: Option<String>,
513 pub bind_check: Option<String>,
514 pub platforms: Vec<String>,
515 pub triggered_by: Vec<String>,
516 pub surfaces: Vec<String>,
517 pub verified_at_sha: Option<String>,
518 pub jurisdiction_map: Option<String>,
519}
520
521fn parse_jurisdiction_map(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
527 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
528 let mut map = std::collections::HashMap::new();
529 for line in text.lines() {
530 let l = line.trim();
531 if l.is_empty() || l.starts_with('#') {
532 continue;
533 }
534 let mut tokens = l.split_whitespace();
535 match (tokens.next(), tokens.next(), tokens.next()) {
536 (Some(key), Some(bucket), None) => {
537 crate::tick::validate_jurisdiction(bucket)
538 .map_err(|e| format!("jurisdiction-map line {l:?}: {e}"))?;
539 map.insert(key.to_string(), bucket.to_string());
540 }
541 _ => {
542 return Err(format!(
543 "jurisdiction-map line {l:?}: expected `<source_key> <bucket>`"
544 ))
545 }
546 }
547 }
548 Ok(map)
549}
550
551fn extract_source(spec: &str) -> Result<Vec<crate::migrate::MigrationRecord>, String> {
555 let (kind, path) = spec
556 .split_once(':')
557 .ok_or_else(|| format!("--source expects <kind>:<path>, got {spec:?}"))?;
558 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
559 let recs = match kind {
560 "canonical" => crate::migrate::canonical_records(&text)?,
563 "gitlog" => crate::migrate::extract_gitlog(&text),
564 "to-human" => crate::migrate::extract_to_human(&text),
565 "decisions-immutable" => crate::migrate::extract_decisions_immutable(&text),
566 "escalation" => crate::migrate::extract_escalation(&text),
567 other => {
568 return Err(format!(
569 "unknown source kind {other:?} (expected canonical | gitlog | to-human | decisions-immutable | escalation)"
570 ))
571 }
572 };
573 Ok(recs)
574}
575
576pub fn migrate(repo: &Path, a: MigrateArgs) -> ExitCode {
577 if let Some(selector) = &a.bind_check {
579 let sha = match crate::capture::resolve_sha(repo, &a.verified_at_sha) {
580 Ok(s) => s,
581 Err(e) => {
582 eprintln!("error: {e}");
583 return ExitCode::FAILURE;
584 }
585 };
586 match crate::migrate::bind_check(
587 selector.clone(),
588 sha,
589 a.platforms.clone(),
590 a.triggered_by.clone(),
591 a.surfaces.clone(),
592 ) {
593 Ok(Check::Test {
594 reference,
595 liveness,
596 ..
597 }) => {
598 println!(
599 "harvested check (falsifiability not proven; no counter-test): {reference:?} on [{}] triggered-by [{}] surface [{}]",
600 liveness.platforms.join(", "),
601 liveness.triggered_by.join(", "),
602 liveness.surfaces.join(", ")
603 );
604 return ExitCode::SUCCESS;
605 }
606 Ok(_) => unreachable!("bind_check yields a Test check"),
607 Err(e) => {
608 eprintln!("error: {e}");
609 return ExitCode::FAILURE;
610 }
611 }
612 }
613
614 if a.reconcile {
616 let against = match &a.against {
617 Some(s) => s,
618 None => {
619 eprintln!("error: --reconcile requires --against <kind>:<path>");
620 return ExitCode::FAILURE;
621 }
622 };
623 let recs = match extract_source(against) {
624 Ok(r) => r,
625 Err(e) => {
626 eprintln!("error: {e}");
627 return ExitCode::FAILURE;
628 }
629 };
630 match crate::migrate::reconcile(repo, &recs) {
631 Ok(rep) => {
632 println!(
633 "reconcile: in-both {}, source-only {} (the capture gap), store-only {}, un-keyable {}",
634 rep.in_both, rep.source_only, rep.store_only, rep.un_keyable
635 );
636 return ExitCode::SUCCESS;
637 }
638 Err(e) => {
639 eprintln!("error: {e}");
640 return ExitCode::FAILURE;
641 }
642 }
643 }
644
645 if a.sources.is_empty() {
647 eprintln!("error: ev migrate needs at least one --source <kind>:<path> (or --reconcile / --bind-check)");
648 return ExitCode::FAILURE;
649 }
650 let mut records = Vec::new();
651 for spec in &a.sources {
652 match extract_source(spec) {
653 Ok(mut r) => records.append(&mut r),
654 Err(e) => {
655 eprintln!("error: {e}");
656 return ExitCode::FAILURE;
657 }
658 }
659 }
660 let jurisdiction_map = match &a.jurisdiction_map {
662 Some(path) => match parse_jurisdiction_map(path) {
663 Ok(m) => m,
664 Err(e) => {
665 eprintln!("error: {e}");
666 return ExitCode::FAILURE;
667 }
668 },
669 None => std::collections::HashMap::new(),
670 };
671 match crate::migrate::backfill(
672 repo,
673 records,
674 a.blame.as_deref(),
675 &jurisdiction_map,
676 a.dry_run,
677 ) {
678 Ok(s) => {
679 if !a.dry_run {
680 crate::events::append(&Store::at(repo), "migrate", None, None, None);
681 }
682 println!(
683 "{}imported {}, skipped {}, re-linked {}, {} source-only gap(s){}",
684 if a.dry_run { "(dry-run) " } else { "" },
685 s.imported,
686 s.skipped,
687 s.relinked,
688 s.source_only_gaps,
689 if s.discrepancies > 0 {
690 format!(", {} discrepancy(ies) — see above", s.discrepancies)
691 } else {
692 String::new()
693 }
694 );
695 ExitCode::SUCCESS
696 }
697 Err(e) => {
698 eprintln!("error: {e}");
699 ExitCode::FAILURE
700 }
701 }
702}
703
704pub fn why(repo: &Path, selector: &str) -> ExitCode {
705 let store = Store::at(repo);
706 if !store.exists() {
707 eprintln!("error: no .evolving/ store here — run `ev init` first");
708 return ExitCode::FAILURE;
709 }
710 let files = match store.read_all() {
711 Ok(f) => f,
712 Err(e) => {
713 eprintln!("error: reading store: {e}");
714 return ExitCode::FAILURE;
715 }
716 };
717 let mut found = false;
718 for (filename, raw) in &files {
719 let t = match crate::tick::from_value(raw) {
720 Ok(t) => t,
721 Err(_) => continue,
722 };
723 if t.status != "live" {
724 continue;
725 }
726 for g in &t.grounds {
727 if let Some(Check::Test { reference, .. }) = &g.check {
728 if reference.as_str() == selector {
729 found = true;
730 println!(
731 "{filename}\t{:?}\tguards: {:?} ({})",
732 t.decision, g.claim, g.supports
733 );
734 }
735 }
736 }
737 }
738 if !found {
739 eprintln!("{selector:?} guards nothing");
740 return ExitCode::FAILURE;
741 }
742 ExitCode::SUCCESS
743}
744
745pub fn list(repo: &Path) -> ExitCode {
747 let store = Store::at(repo);
748 if !store.exists() {
749 eprintln!("error: no .evolving/ store here — run `ev init` first");
750 return ExitCode::FAILURE;
751 }
752 let files = match store.read_all() {
753 Ok(f) => f,
754 Err(e) => {
755 eprintln!("error: reading store: {e}");
756 return ExitCode::FAILURE;
757 }
758 };
759 let mut parsed: Vec<(String, Tick)> = Vec::new();
765 let mut rows: Vec<String> = Vec::new();
766 for (name, raw) in &files {
767 match crate::tick::from_value(raw) {
768 Ok(t) => parsed.push((name.clone(), t)),
769 Err(_) => rows.push(format!("{name}\t?\t\"<unparseable>\"")),
770 }
771 }
772 for (name, t) in current_decisions(parsed) {
773 let mut l = format!("{name}\t{}\t{:?}", t.status, t.decision);
774 if let Some(a) = &t.authority {
775 l.push_str(&format!("\tauthority={a}"));
776 }
777 if let Some(j) = &t.jurisdiction {
778 l.push_str(&format!("\tjurisdiction={j}"));
779 }
780 if let Some(r) = &t.source_ref {
781 l.push_str(&format!("\tsource_ref={}", render_source_ref(r)));
782 }
783 rows.push(l);
784 }
785 rows.sort();
786 if rows.is_empty() {
787 println!("no decisions yet");
788 return ExitCode::SUCCESS;
789 }
790 for line in &rows {
791 println!("{line}");
792 }
793 ExitCode::SUCCESS
794}
795
796fn load_bearing(t: &Tick) -> bool {
800 t.grounds
801 .iter()
802 .any(|g| g.supports.starts_with("rejected:"))
803}
804
805fn brief_visible(t: &Tick) -> bool {
816 t.status == "live"
817 && t.authority.as_deref() == Some("user-ruled")
818 && t.provenance.as_deref() != Some("agent-proposed")
819}
820
821fn brief_json(kept: &[(String, Tick)], total: usize, dropped_lb: usize) -> String {
826 let decisions: Vec<Value> = kept
827 .iter()
828 .map(|(_, t)| {
829 let rejected_roads: Vec<Value> = t
830 .grounds
831 .iter()
832 .filter_map(|g| {
833 g.supports
834 .strip_prefix("rejected:")
835 .map(|option| json!({ "option": option, "claim": g.claim }))
836 })
837 .collect();
838 let mut d = json!({
839 "id": t.id,
840 "decision": t.decision,
841 "load_bearing": load_bearing(t),
842 "rejected_roads": rejected_roads,
843 });
844 if let (Some(sr), Some(obj)) = (&t.source_ref, d.as_object_mut()) {
846 obj.insert(
847 "source_ref".into(),
848 Value::String(crate::tick::source_ref_key(sr)),
849 );
850 }
851 d
852 })
853 .collect();
854 let payload = json!({
855 "kind": "ev-brief",
856 "decisions": decisions,
857 "shown": kept.len(),
858 "total": total,
859 "elided": total - kept.len(),
860 "elided_load_bearing": dropped_lb,
861 });
862 format!(
867 "{}\n",
868 serde_json::to_string(&payload).expect("ev-brief payload serializes")
869 )
870}
871
872pub fn brief(repo: &Path, limit: Option<usize>, json: bool) -> ExitCode {
873 let store = Store::at(repo);
874 if !store.exists() {
875 eprintln!("error: no .evolving/ store here — run `ev init` first");
876 return ExitCode::FAILURE;
877 }
878 let files = match store.read_all() {
879 Ok(f) => f,
880 Err(e) => {
881 eprintln!("error: reading store: {e}");
882 return ExitCode::FAILURE;
883 }
884 };
885 let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
887 let all: Vec<(String, Tick)> = files
890 .iter()
891 .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
892 .collect();
893 let mut kept: Vec<(String, Tick)> = current_decisions(all)
894 .into_iter()
895 .filter(|(_, t)| brief_visible(t))
896 .collect();
897 let lb = load_bearing;
898 kept.sort_by(|a, b| {
901 lb(&b.1)
902 .cmp(&lb(&a.1))
903 .then(b.1.held_since.cmp(&a.1.held_since))
904 .then(b.0.cmp(&a.0))
905 });
906 let total = kept.len();
907 let n = if limit == 0 { total } else { limit.min(total) };
909 let dropped_lb = kept[n..].iter().filter(|(_, t)| lb(t)).count();
911 kept.truncate(n);
912
913 if json {
915 print!("{}", brief_json(&kept, total, dropped_lb));
916 return ExitCode::SUCCESS;
917 }
918 if kept.is_empty() {
919 println!("no user-ruled decisions");
920 return ExitCode::SUCCESS;
921 }
922 for (_id, t) in &kept {
923 println!("{} [user-ruled]", t.decision);
924 for g in &t.grounds {
925 if let Some(option) = g.supports.strip_prefix("rejected:") {
926 println!(" rejected {option}: {}", g.claim);
927 }
928 }
929 }
930 if total > n {
931 let dropped = total - n;
932 let lb_clause = if dropped_lb > 0 {
933 format!(", {dropped_lb} with rejected roads")
934 } else {
935 String::new()
936 };
937 println!("… {dropped} more user-ruled decision(s){lb_clause} — `ev list` for all");
938 }
939 ExitCode::SUCCESS
940}
941
942pub fn log(repo: &Path) -> ExitCode {
944 let store = Store::at(repo);
945 if !store.exists() {
946 eprintln!("error: no .evolving/ store here — run `ev init` first");
947 return ExitCode::FAILURE;
948 }
949 let mut id = match store.read_head() {
950 Ok(h) => h,
951 Err(e) => {
952 eprintln!("error: reading HEAD: {e}");
953 return ExitCode::FAILURE;
954 }
955 };
956 if id.is_empty() {
957 println!("no decisions yet");
958 return ExitCode::SUCCESS;
959 }
960 let mut seen = std::collections::HashSet::new();
961 while !id.is_empty() {
962 if !seen.insert(id.clone()) {
963 break; }
965 match store.read_tick(&id) {
966 Ok(Some(t)) => {
967 println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
968 id = t.parent_id;
969 }
970 Ok(None) => {
971 eprintln!("warning: {id} not found (broken lineage)");
972 break;
973 }
974 Err(e) => {
975 eprintln!("error: reading {id}: {e}");
976 return ExitCode::FAILURE;
977 }
978 }
979 }
980 ExitCode::SUCCESS
981}
982
983pub fn reopen(repo: &Path, id: &str) -> ExitCode {
984 let store = Store::at(repo);
985 let tick = match store.read_tick(id) {
986 Ok(Some(t)) => t,
987 Ok(None) => {
988 eprintln!("error: no tick with id {id}");
989 return ExitCode::FAILURE;
990 }
991 Err(e) => {
992 eprintln!("error: reading {id}: {e}");
993 return ExitCode::FAILURE;
994 }
995 };
996 let config = crate::config::read(&store);
997 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
998 let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
999
1000 crate::events::append(&store, "reopen", Some(&tick), None, None);
1001 println!("decision {}: {:?}", tick.id, tick.decision);
1002 if !tick.observe.is_empty() {
1003 println!("observe: {:?}", tick.observe);
1004 }
1005 if let Some(a) = &tick.authority {
1006 println!("authority: {a}");
1007 }
1008 if let Some(j) = &tick.jurisdiction {
1009 println!("jurisdiction: {j}");
1010 }
1011 if let Some(r) = &tick.source_ref {
1012 println!("source_ref: {}", render_source_ref(r));
1013 }
1014 for g in &tick.grounds {
1015 match &g.check {
1016 Some(Check::Test {
1017 reference,
1018 verified_at_sha,
1019 ..
1020 }) => {
1021 let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
1022 let ts = triggered_since(repo, g, &receipts);
1023 let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
1024 let now = v.label();
1025 let short = &verified_at_sha[..verified_at_sha.len().min(8)];
1026 println!(
1027 " [{}] {:?} — test {:?} frozen@{short} now: {now}",
1028 g.supports, g.claim, reference
1029 );
1030 }
1031 Some(Check::Person { reference }) => {
1032 println!(" [{}] {:?} — person {:?}", g.supports, g.claim, reference);
1033 }
1034 None => {
1035 println!(" [{}] {:?}", g.supports, g.claim);
1036 }
1037 }
1038 }
1039 ExitCode::SUCCESS
1040}
1041
1042fn self_test_golden() -> ExitCode {
1044 let genesis = Tick {
1045 id: String::new(),
1046 parent_id: "".into(),
1047 observe: "evaluating retrieval backend".into(),
1048 decision: "freeze the retrieval schema for v2".into(),
1049 grounds: vec![
1050 Ground {
1051 claim: "team still wants a frozen schema".into(),
1052 supports: "chosen".into(),
1053 check: Some(Check::Person {
1054 reference: "Q3 infra review".into(),
1055 }),
1056 },
1057 Ground {
1058 claim: "pgvector would lock our schema".into(),
1059 supports: "rejected:pgvector".into(),
1060 check: None,
1061 },
1062 ],
1063 status: "live".into(),
1064 held_since: "".into(),
1065 blame: "Wang Yu".into(),
1066 authority: None,
1067 jurisdiction: None,
1068 source_ref: None,
1069 provenance: None,
1070 };
1071 let case1 = Tick {
1072 id: String::new(),
1073 parent_id: "7b21f0a4c8de".into(),
1074 observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
1075 decision: "restore-safety counter DB-backed; reject Redis".into(),
1076 grounds: vec![
1077 Ground {
1078 claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
1079 supports: "chosen".into(),
1080 check: Some(Check::Test {
1081 reference: "pytest tests/test_redis_absent.py".into(),
1082 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
1083 counter_test: Some(
1084 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
1085 ),
1086 liveness: Liveness {
1087 platforms: vec!["linux-ci".into()],
1088 triggered_by: vec!["pyproject.toml".into()],
1089 surfaces: vec!["pyproject-deps".into()],
1090 },
1091 }),
1092 },
1093 Ground {
1094 claim: "team still wants 0-Redis posture".into(),
1095 supports: "chosen".into(),
1096 check: Some(Check::Person {
1097 reference: "Q3 infra review".into(),
1098 }),
1099 },
1100 Ground {
1101 claim: "Redis would add a new infra dependency".into(),
1102 supports: "rejected:Redis".into(),
1103 check: None,
1104 },
1105 ],
1106 status: "live".into(),
1107 held_since: "".into(),
1108 blame: "Wang Yu".into(),
1109 authority: None,
1110 jurisdiction: None,
1111 source_ref: None,
1112 provenance: None,
1113 };
1114 let mut harvested = case1.clone();
1117 if let Some(Check::Test { counter_test, .. }) = &mut harvested.grounds[0].check {
1118 *counter_test = None;
1119 }
1120 let mut rejected_tripwire = case1.clone();
1125 rejected_tripwire.authority = Some("user-ruled".into());
1126 rejected_tripwire.grounds[2].check = Some(Check::Test {
1127 reference: "! grep -q redis pyproject.toml".into(),
1128 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
1129 counter_test: Some("grep -q redis pyproject.toml".into()),
1130 liveness: Liveness {
1131 platforms: vec!["linux-ci".into()],
1132 triggered_by: vec!["pyproject.toml".into()],
1133 surfaces: vec!["pyproject-deps".into()],
1134 },
1135 });
1136 let mut ok = true;
1137 for (name, t, want) in [
1138 ("genesis", &genesis, "e2b337f53a1f"),
1139 ("case1", &case1, "638c47b0c9dd"),
1140 ("harvested", &harvested, "0cf784b51331"),
1141 ("rejected_tripwire", &rejected_tripwire, "9c5feb4582ac"),
1142 ] {
1143 let got = compute_id(t);
1144 let pass = got == want;
1145 ok &= pass;
1146 println!(
1147 "{} {name}: {got} (want {want})",
1148 if pass { "✓" } else { "✗" }
1149 );
1150 }
1151 if ok {
1152 ExitCode::SUCCESS
1153 } else {
1154 ExitCode::FAILURE
1155 }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::roll_up_check;
1161 use crate::verdict::{StaleKind, Verdict};
1162
1163 fn stale_sha() -> Verdict {
1164 Verdict::Stale {
1165 kind: StaleKind::Sha,
1166 reason: String::new(),
1167 }
1168 }
1169
1170 #[test]
1171 fn roll_up_check_should_emit_nothing_when_there_is_no_test_bound_ground() {
1172 assert_eq!(roll_up_check(&[]), None);
1174 }
1175
1176 #[test]
1177 fn roll_up_check_should_carry_the_worst_verdict_red_over_green() {
1178 let (g, r) = (Verdict::Green, Verdict::Red);
1180 assert_eq!(roll_up_check(&[&g, &r]), Some(("red".to_string(), None)));
1181 }
1182
1183 #[test]
1184 fn roll_up_check_should_let_a_gating_silently_unbound_outrank_a_co_occurring_green() {
1185 let (su, g) = (Verdict::SilentlyUnbound, Verdict::Green);
1188 assert_eq!(
1189 roll_up_check(&[&su, &g]),
1190 Some(("silently-unbound".to_string(), None))
1191 );
1192 assert_eq!(
1193 roll_up_check(&[&g, &su]),
1194 Some(("silently-unbound".to_string(), None))
1195 );
1196 }
1197
1198 #[test]
1199 fn roll_up_check_should_carry_the_stale_sub_kind_when_stale_is_the_worst() {
1200 let (s, nr) = (
1202 stale_sha(),
1203 Verdict::NotRun {
1204 missing_platforms: vec!["p".to_string()],
1205 },
1206 );
1207 assert_eq!(
1208 roll_up_check(&[&s, &nr]),
1209 Some(("stale:sha".to_string(), None))
1210 );
1211 }
1212
1213 #[test]
1214 fn roll_up_check_should_surface_a_stale_masked_behind_a_red() {
1215 let (r, s) = (Verdict::Red, stale_sha());
1218 assert_eq!(
1219 roll_up_check(&[&r, &s]),
1220 Some(("red".to_string(), Some("stale:sha".to_string())))
1221 );
1222 }
1223
1224 #[test]
1225 fn roll_up_check_should_emit_green_when_every_ground_is_green() {
1226 let (a, b) = (Verdict::Green, Verdict::Green);
1227 assert_eq!(roll_up_check(&[&a, &b]), Some(("green".to_string(), None)));
1228 }
1229}