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