Skip to main content

ev/
cmd.rs

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
8/// Whether a triggering change landed after this ground's most recent run. Uses the latest
9/// receipt's commit + the binding's triggered_by paths. False when there is no receipt, no
10/// Test binding, or git can't tell (None ⇒ not evaluated).
11fn triggered_since(
12    repo: &std::path::Path,
13    ground: &crate::tick::Ground,
14    receipts: &[crate::receipt::Receipt],
15) -> bool {
16    use crate::tick::Check;
17    let triggered_by = match &ground.check {
18        Some(Check::Test { liveness, .. }) => &liveness.triggered_by,
19        _ => return false,
20    };
21    let latest = receipts.iter().max_by(|a, b| a.ran_at.cmp(&b.ran_at));
22    match latest {
23        Some(r) => crate::liveness::changed_since(repo, &r.commit, triggered_by).unwrap_or(false),
24        None => false,
25    }
26}
27
28pub fn init(repo: &Path) -> ExitCode {
29    let store = Store::at(repo);
30    match store.init() {
31        Ok(true) => {
32            println!("created .evolving/  (content-addressed chain + results cache)");
33            ExitCode::SUCCESS
34        }
35        Ok(false) => {
36            println!(".evolving/ already exists (no-op)");
37            ExitCode::SUCCESS
38        }
39        Err(e) => {
40            eprintln!("error: could not create .evolving/: {e}");
41            ExitCode::FAILURE
42        }
43    }
44}
45pub fn show(repo: &Path, id: &str) -> ExitCode {
46    let store = Store::at(repo);
47    let path = store.ticks_dir().join(id);
48    if !path.is_file() {
49        eprintln!("error: no tick with id {id}");
50        return ExitCode::FAILURE;
51    }
52    match std::fs::read_to_string(&path) {
53        Ok(text) => {
54            // print as-is (the on-disk pretty JSON: hashed payload + bookkeeping).
55            println!("{text}");
56            // surface the declared authority on its own line when present (boot-time read).
57            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
58                if let Some(a) = v.get("authority").and_then(|x| x.as_str()) {
59                    println!("authority: {a}");
60                }
61            }
62            ExitCode::SUCCESS
63        }
64        Err(e) => {
65            eprintln!("error: reading {id}: {e}");
66            ExitCode::FAILURE
67        }
68    }
69}
70pub fn decide(repo: &Path, decision: Option<&str>, args: &[String]) -> ExitCode {
71    // clap fills the optional positional with the first token even when it is a flag (it carries
72    // allow_hyphen_values so a leading --from-git can reach us at all). A real decision never
73    // starts with '-', so a hyphen-leading "decision" is actually a flag: re-route it into args
74    // and leave the positional empty, letting the capture flag-loop own --from-git uniformly.
75    let (decision, args): (Option<&str>, Vec<String>) = match decision {
76        Some(d) if d.starts_with('-') => {
77            let mut v = vec![d.to_string()];
78            v.extend_from_slice(args);
79            (None, v)
80        }
81        other => (other, args.to_vec()),
82    };
83    match crate::capture::run(repo, decision, &args) {
84        Ok(t) => {
85            crate::events::append(&Store::at(repo), "decide", Some(&t.id), None);
86            println!("recorded {} ({} ground(s))", t.id, t.grounds.len());
87            ExitCode::SUCCESS
88        }
89        Err(e) => {
90            eprintln!("error: {e}");
91            ExitCode::FAILURE
92        }
93    }
94}
95
96pub fn guard(repo: &Path, a: crate::guard::GuardArgs) -> ExitCode {
97    match crate::guard::run(repo, a) {
98        Ok(t) => {
99            crate::events::append(&Store::at(repo), "guard", Some(&t.id), None);
100            println!("bound; wrote child {}", t.id);
101            ExitCode::SUCCESS
102        }
103        Err(e) => {
104            eprintln!("error: {e}");
105            ExitCode::FAILURE
106        }
107    }
108}
109
110pub fn verify_cmd(repo: &Path, self_test: bool) -> ExitCode {
111    if self_test {
112        return self_test_golden();
113    }
114    let store = Store::at(repo);
115    match verify(&store) {
116        Ok(v) if v.is_empty() => {
117            println!("✓ chain intact: every id == hash(payload), lineage forward-only");
118            println!("✓ every tick validates against the closed schema (R1) and check shape (R2)");
119            ExitCode::SUCCESS
120        }
121        Ok(v) => {
122            for line in &v {
123                println!("✗ {line}");
124            }
125            eprintln!("{} violation(s)", v.len());
126            ExitCode::FAILURE
127        }
128        Err(e) => {
129            eprintln!("error: reading store: {e}");
130            ExitCode::FAILURE
131        }
132    }
133}
134
135/// The latest ran_at across a ref's receipts (for the display line), if any.
136/// `receipts` is already scoped to one ref by `read_for`, so no filtering is needed.
137fn latest_ran_at(receipts: &[crate::receipt::Receipt]) -> Option<String> {
138    receipts.iter().map(|r| r.ran_at.clone()).max()
139}
140
141/// The evaluation context for one `ev check` / `ev reopen` invocation: the staleness reference
142/// (resolved per policy by the caller), the selected-list, the wall clock, and the staleness
143/// window. The I/O assembly lives here in the command layer so `verdict::verdict_for` stays pure.
144fn live_ctx(
145    store: &Store,
146    staleness_days: u64,
147    live_origin_sha: Option<String>,
148    attest: Option<Vec<String>>,
149) -> crate::verdict::Ctx {
150    crate::verdict::Ctx {
151        live_origin_sha,
152        selected: crate::selected::read(store).unwrap_or(None),
153        now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
154        staleness_secs: staleness_days as i64 * 86_400,
155        attest,
156    }
157}
158
159pub fn check(
160    repo: &Path,
161    exit_on_red: bool,
162    run: bool,
163    platform: &str,
164    offline: bool,
165    attest: Vec<String>,
166) -> ExitCode {
167    use crate::verdict::{verdict_for, Verdict};
168    let store = Store::at(repo);
169    if !store.exists() {
170        eprintln!("error: no .evolving/ store here — run `ev init` first");
171        return ExitCode::FAILURE;
172    }
173    let files = match store.read_all() {
174        Ok(f) => f,
175        Err(e) => {
176            eprintln!("error: reading store: {e}");
177            return ExitCode::FAILURE;
178        }
179    };
180    let config = crate::config::read(&store);
181
182    // --run pass: for every live Test-bound ground that declares this platform, run the
183    // bound ref locally and append a receipt for it (one local run = one platform receipt).
184    if run {
185        for (_filename, raw) in &files {
186            let t = match crate::tick::from_value(raw) {
187                Ok(t) => t,
188                Err(_) => continue,
189            };
190            if t.status != "live" {
191                continue;
192            }
193            for g in &t.grounds {
194                if let Some(Check::Test {
195                    reference,
196                    counter_test,
197                    liveness,
198                    ..
199                }) = &g.check
200                {
201                    if liveness.platforms.iter().any(|p| p == platform) {
202                        // run the bound check
203                        match crate::runner::run_check(
204                            repo,
205                            reference,
206                            platform,
207                            config.green_exit_code,
208                        ) {
209                            Ok(mut rc) => {
210                                // prove falsifiability: the counter-test must produce the OPPOSITE result
211                                if let Ok(ct) = crate::runner::run_check(
212                                    repo,
213                                    counter_test,
214                                    platform,
215                                    config.green_exit_code,
216                                ) {
217                                    rc.falsifiable = Some(rc.result != ct.result);
218                                }
219                                if let Err(e) = crate::receipt::append(&store, &rc) {
220                                    eprintln!(
221                                        "warning: could not write receipt for {reference:?}: {e}"
222                                    );
223                                }
224                            }
225                            Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
226                        }
227                    }
228                }
229            }
230        }
231    }
232
233    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
234    let attest = if attest.is_empty() {
235        None
236    } else {
237        Some(attest)
238    };
239    let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
240    let mut rows: Vec<String> = Vec::new();
241    let mut any_not_green = false;
242
243    for (filename, raw) in &files {
244        let t = match crate::tick::from_value(raw) {
245            Ok(t) => t,
246            Err(_) => continue, // ev verify owns schema errors; check skips unparsable ticks
247        };
248        if t.status != "live" {
249            continue;
250        }
251        let mut verdicts = Vec::with_capacity(t.grounds.len());
252        for g in &t.grounds {
253            // Receipts are read only for Test-bound grounds; person/unbound need none.
254            let receipts = match &g.check {
255                Some(Check::Test { reference, .. }) => {
256                    crate::receipt::read_for(&store, reference).unwrap_or_default()
257                }
258                _ => Vec::new(),
259            };
260            // verdict_for returns NotApplicable for any non-Test ground.
261            let ts = triggered_since(repo, g, &receipts);
262            let v = verdict_for(g, &receipts, &ctx, ts);
263            if !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt) {
264                any_not_green = true;
265            }
266            // Only Test-bound grounds appear in the printed set and the gate.
267            if matches!(&g.check, Some(Check::Test { .. })) {
268                let detail = match &v {
269                    Verdict::NotRun { missing_platforms } => {
270                        format!("missing: {}", missing_platforms.join(", "))
271                    }
272                    Verdict::Stale { reason } => reason.clone(),
273                    _ => latest_ran_at(&receipts)
274                        .map(|ts| format!("ran {ts}"))
275                        .unwrap_or_else(|| "no receipt".into()),
276                };
277                rows.push(format!(
278                    "{}\t{filename}\t{:?}\t({detail})",
279                    v.label(),
280                    g.claim
281                ));
282                crate::events::append(&store, "check", Some(&t.id), Some(v.label()));
283            }
284            verdicts.push((g, v));
285        }
286        // The per-host verdict-cache read contract for this tick (a hook reads it without shelling check).
287        let _ = crate::state::write_state(
288            &store,
289            &t.id,
290            &verdicts,
291            &config.staleness_ref,
292            ctx.live_origin_sha.as_deref(),
293        );
294    }
295
296    if rows.is_empty() {
297        println!("no test-bound grounds to check");
298    } else {
299        for r in &rows {
300            println!("{r}");
301        }
302        if !run {
303            // under --run the verdict itself carries falsifiability (an `unproven` row is a
304            // counter-test that did not flip); without it, point at the proof step rather than
305            // implying one already happened.
306            println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
307        }
308    }
309    if exit_on_red && any_not_green {
310        return ExitCode::FAILURE;
311    }
312    ExitCode::SUCCESS
313}
314
315pub fn why(repo: &Path, selector: &str) -> ExitCode {
316    let store = Store::at(repo);
317    if !store.exists() {
318        eprintln!("error: no .evolving/ store here — run `ev init` first");
319        return ExitCode::FAILURE;
320    }
321    let files = match store.read_all() {
322        Ok(f) => f,
323        Err(e) => {
324            eprintln!("error: reading store: {e}");
325            return ExitCode::FAILURE;
326        }
327    };
328    let mut found = false;
329    for (filename, raw) in &files {
330        let t = match crate::tick::from_value(raw) {
331            Ok(t) => t,
332            Err(_) => continue,
333        };
334        if t.status != "live" {
335            continue;
336        }
337        for g in &t.grounds {
338            if let Some(Check::Test { reference, .. }) = &g.check {
339                if reference.as_str() == selector {
340                    found = true;
341                    println!(
342                        "{filename}\t{:?}\tguards: {:?} ({})",
343                        t.decision, g.claim, g.supports
344                    );
345                }
346            }
347        }
348    }
349    if !found {
350        eprintln!("{selector:?} guards nothing");
351        return ExitCode::FAILURE;
352    }
353    ExitCode::SUCCESS
354}
355
356/// List every decision in the ledger: id, status, decision (sorted by id, deterministic).
357pub fn list(repo: &Path) -> ExitCode {
358    let store = Store::at(repo);
359    if !store.exists() {
360        eprintln!("error: no .evolving/ store here — run `ev init` first");
361        return ExitCode::FAILURE;
362    }
363    let files = match store.read_all() {
364        Ok(f) => f,
365        Err(e) => {
366            eprintln!("error: reading store: {e}");
367            return ExitCode::FAILURE;
368        }
369    };
370    let mut rows: Vec<(String, String, String, Option<String>)> = files
371        .iter()
372        .map(|(name, raw)| match crate::tick::from_value(raw) {
373            Ok(t) => (name.clone(), t.status, t.decision, t.authority),
374            Err(_) => (name.clone(), "?".into(), "<unparseable>".into(), None),
375        })
376        .collect();
377    rows.sort();
378    if rows.is_empty() {
379        println!("no decisions yet");
380        return ExitCode::SUCCESS;
381    }
382    for (id, status, decision, authority) in &rows {
383        match authority {
384            Some(a) => println!("{id}\t{status}\t{decision:?}\tauthority={a}"),
385            None => println!("{id}\t{status}\t{decision:?}"),
386        }
387    }
388    ExitCode::SUCCESS
389}
390
391/// Boot-read: the live user-ruled decisions and the roads they rejected. A near-zero-cost,
392/// 0-network read (read_all only; no git, no receipts) for a fresh agent to load the
393/// decisions it must respect and the options it must not re-propose. Ordered most-recent-first
394/// (by held_since), capped to the effective limit, with an honest remainder footer.
395pub fn brief(repo: &Path, limit: Option<usize>) -> ExitCode {
396    let store = Store::at(repo);
397    if !store.exists() {
398        eprintln!("error: no .evolving/ store here — run `ev init` first");
399        return ExitCode::FAILURE;
400    }
401    let files = match store.read_all() {
402        Ok(f) => f,
403        Err(e) => {
404            eprintln!("error: reading store: {e}");
405            return ExitCode::FAILURE;
406        }
407    };
408    // The flag overrides config; 0 (here or in config) means "show all".
409    let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
410    // Keep only live, user-ruled decisions; carry the id so output is deterministic.
411    let mut kept: Vec<(String, Tick)> = files
412        .iter()
413        .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
414        .filter(|(_, t)| t.status == "live" && t.authority.as_deref() == Some("user-ruled"))
415        .collect();
416    // Most-recent-first by held_since; tie-break by id descending so output is deterministic.
417    kept.sort_by(|a, b| b.1.held_since.cmp(&a.1.held_since).then(b.0.cmp(&a.0)));
418    if kept.is_empty() {
419        println!("no user-ruled decisions");
420        return ExitCode::SUCCESS;
421    }
422    let total = kept.len();
423    if limit > 0 {
424        kept.truncate(limit);
425    }
426    for (_id, t) in &kept {
427        println!("{}  [user-ruled]", t.decision);
428        for g in &t.grounds {
429            if let Some(option) = g.supports.strip_prefix("rejected:") {
430                println!("  rejected {option}: {}", g.claim);
431            }
432        }
433    }
434    if total > kept.len() {
435        println!(
436            "… {} more user-ruled decision(s) — `ev list` for all",
437            total - kept.len()
438        );
439    }
440    ExitCode::SUCCESS
441}
442
443/// Show the decision lineage from HEAD back to genesis (newest first).
444pub fn log(repo: &Path) -> ExitCode {
445    let store = Store::at(repo);
446    if !store.exists() {
447        eprintln!("error: no .evolving/ store here — run `ev init` first");
448        return ExitCode::FAILURE;
449    }
450    let mut id = match store.read_head() {
451        Ok(h) => h,
452        Err(e) => {
453            eprintln!("error: reading HEAD: {e}");
454            return ExitCode::FAILURE;
455        }
456    };
457    if id.is_empty() {
458        println!("no decisions yet");
459        return ExitCode::SUCCESS;
460    }
461    let mut seen = std::collections::HashSet::new();
462    while !id.is_empty() {
463        if !seen.insert(id.clone()) {
464            break; // cycle guard (a content-addressed chain can't cycle, but never loop)
465        }
466        match store.read_tick(&id) {
467            Ok(Some(t)) => {
468                println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
469                id = t.parent_id;
470            }
471            Ok(None) => {
472                eprintln!("warning: {id} not found (broken lineage)");
473                break;
474            }
475            Err(e) => {
476                eprintln!("error: reading {id}: {e}");
477                return ExitCode::FAILURE;
478            }
479        }
480    }
481    ExitCode::SUCCESS
482}
483
484pub fn reopen(repo: &Path, id: &str) -> ExitCode {
485    let store = Store::at(repo);
486    let tick = match store.read_tick(id) {
487        Ok(Some(t)) => t,
488        Ok(None) => {
489            eprintln!("error: no tick with id {id}");
490            return ExitCode::FAILURE;
491        }
492        Err(e) => {
493            eprintln!("error: reading {id}: {e}");
494            return ExitCode::FAILURE;
495        }
496    };
497    let config = crate::config::read(&store);
498    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
499    let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
500
501    crate::events::append(&store, "reopen", Some(id), None);
502    println!("decision {}: {:?}", tick.id, tick.decision);
503    if !tick.observe.is_empty() {
504        println!("observe: {:?}", tick.observe);
505    }
506    if let Some(a) = &tick.authority {
507        println!("authority: {a}");
508    }
509    for g in &tick.grounds {
510        match &g.check {
511            Some(Check::Test {
512                reference,
513                verified_at_sha,
514                ..
515            }) => {
516                let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
517                let ts = triggered_since(repo, g, &receipts);
518                let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
519                let now = v.label();
520                let short = &verified_at_sha[..verified_at_sha.len().min(8)];
521                println!(
522                    "  [{}] {:?} — test {:?} frozen@{short} now: {now}",
523                    g.supports, g.claim, reference
524                );
525            }
526            Some(Check::Person { reference }) => {
527                println!("  [{}] {:?} — person {:?}", g.supports, g.claim, reference);
528            }
529            None => {
530                println!("  [{}] {:?}", g.supports, g.claim);
531            }
532        }
533    }
534    ExitCode::SUCCESS
535}
536
537/// Reproduce the two frozen golden vectors; non-zero if either id drifts.
538fn self_test_golden() -> ExitCode {
539    let genesis = Tick {
540        id: String::new(),
541        parent_id: "".into(),
542        observe: "evaluating retrieval backend".into(),
543        decision: "freeze the retrieval schema for v2".into(),
544        grounds: vec![
545            Ground {
546                claim: "team still wants a frozen schema".into(),
547                supports: "chosen".into(),
548                check: Some(Check::Person {
549                    reference: "Q3 infra review".into(),
550                }),
551            },
552            Ground {
553                claim: "pgvector would lock our schema".into(),
554                supports: "rejected:pgvector".into(),
555                check: None,
556            },
557        ],
558        status: "live".into(),
559        held_since: "".into(),
560        blame: "Wang Yu".into(),
561        authority: None,
562    };
563    let case1 = Tick {
564        id: String::new(),
565        parent_id: "7b21f0a4c8de".into(),
566        observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
567        decision: "restore-safety counter DB-backed; reject Redis".into(),
568        grounds: vec![
569            Ground {
570                claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
571                supports: "chosen".into(),
572                check: Some(Check::Test {
573                    reference: "pytest tests/test_redis_absent.py".into(),
574                    verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
575                    counter_test:
576                        "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
577                    liveness: Liveness {
578                        platforms: vec!["linux-ci".into()],
579                        triggered_by: vec!["pyproject.toml".into()],
580                        surfaces: vec!["pyproject-deps".into()],
581                    },
582                }),
583            },
584            Ground {
585                claim: "team still wants 0-Redis posture".into(),
586                supports: "chosen".into(),
587                check: Some(Check::Person {
588                    reference: "Q3 infra review".into(),
589                }),
590            },
591            Ground {
592                claim: "Redis would add a new infra dependency".into(),
593                supports: "rejected:Redis".into(),
594                check: None,
595            },
596        ],
597        status: "live".into(),
598        held_since: "".into(),
599        blame: "Wang Yu".into(),
600        authority: None,
601    };
602    let mut ok = true;
603    for (name, t, want) in [
604        ("genesis", &genesis, "e2b337f53a1f"),
605        ("case1", &case1, "638c47b0c9dd"),
606    ] {
607        let got = compute_id(t);
608        let pass = got == want;
609        ok &= pass;
610        println!(
611            "{} {name}: {got} (want {want})",
612            if pass { "✓" } else { "✗" }
613        );
614    }
615    if ok {
616        ExitCode::SUCCESS
617    } else {
618        ExitCode::FAILURE
619    }
620}