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                if let Some(j) = v.get("jurisdiction").and_then(|x| x.as_str()) {
62                    println!("jurisdiction: {j}");
63                }
64                if let Some(r) = v.get("round_id").and_then(|x| x.as_str()) {
65                    println!("round_id: {r}");
66                }
67            }
68            ExitCode::SUCCESS
69        }
70        Err(e) => {
71            eprintln!("error: reading {id}: {e}");
72            ExitCode::FAILURE
73        }
74    }
75}
76pub fn decide(repo: &Path, decision: Option<&str>, args: &[String]) -> ExitCode {
77    // clap fills the optional positional with the first token even when it is a flag (it carries
78    // allow_hyphen_values so a leading --from-git can reach us at all). A real decision never
79    // starts with '-', so a hyphen-leading "decision" is actually a flag: re-route it into args
80    // and leave the positional empty, letting the capture flag-loop own --from-git uniformly.
81    let (decision, args): (Option<&str>, Vec<String>) = match decision {
82        Some(d) if d.starts_with('-') => {
83            let mut v = vec![d.to_string()];
84            v.extend_from_slice(args);
85            (None, v)
86        }
87        other => (other, args.to_vec()),
88    };
89    match crate::capture::run(repo, decision, &args) {
90        Ok(t) => {
91            crate::events::append(&Store::at(repo), "decide", Some(&t.id), None);
92            println!("recorded {} ({} ground(s))", t.id, t.grounds.len());
93            ExitCode::SUCCESS
94        }
95        Err(e) => {
96            eprintln!("error: {e}");
97            ExitCode::FAILURE
98        }
99    }
100}
101
102pub fn guard(repo: &Path, a: crate::guard::GuardArgs) -> ExitCode {
103    match crate::guard::run(repo, a) {
104        Ok(t) => {
105            crate::events::append(&Store::at(repo), "guard", Some(&t.id), None);
106            println!("bound; wrote child {}", t.id);
107            ExitCode::SUCCESS
108        }
109        Err(e) => {
110            eprintln!("error: {e}");
111            ExitCode::FAILURE
112        }
113    }
114}
115
116pub fn verify_cmd(repo: &Path, self_test: bool) -> ExitCode {
117    if self_test {
118        return self_test_golden();
119    }
120    let store = Store::at(repo);
121    // Forward-compat: tolerated unknown top-level keys are warnings, never violations — they do
122    // not affect the verdict, but they keep a typo'd field name visible.
123    for w in crate::verify::unknown_key_warnings(&store).unwrap_or_default() {
124        eprintln!("{w}");
125    }
126    match verify(&store) {
127        Ok(v) if v.is_empty() => {
128            println!("✓ chain intact: every id == hash(payload), lineage forward-only");
129            println!("✓ every tick validates against the closed schema (R1) and check shape (R2)");
130            ExitCode::SUCCESS
131        }
132        Ok(v) => {
133            for line in &v {
134                println!("✗ {line}");
135            }
136            eprintln!("{} violation(s)", v.len());
137            ExitCode::FAILURE
138        }
139        Err(e) => {
140            eprintln!("error: reading store: {e}");
141            ExitCode::FAILURE
142        }
143    }
144}
145
146/// The latest ran_at across a ref's receipts (for the display line), if any.
147/// `receipts` is already scoped to one ref by `read_for`, so no filtering is needed.
148fn latest_ran_at(receipts: &[crate::receipt::Receipt]) -> Option<String> {
149    receipts.iter().map(|r| r.ran_at.clone()).max()
150}
151
152/// The evaluation context for one `ev check` / `ev reopen` invocation: the staleness reference
153/// (resolved per policy by the caller), the selected-list, the wall clock, and the staleness
154/// window. The I/O assembly lives here in the command layer so `verdict::verdict_for` stays pure.
155fn live_ctx(
156    store: &Store,
157    staleness_days: u64,
158    live_origin_sha: Option<String>,
159    attest: Option<Vec<String>>,
160) -> crate::verdict::Ctx {
161    crate::verdict::Ctx {
162        live_origin_sha,
163        selected: crate::selected::read(store).unwrap_or(None),
164        now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
165        staleness_secs: staleness_days as i64 * 86_400,
166        attest,
167    }
168}
169
170pub fn check(
171    repo: &Path,
172    exit_on_red: bool,
173    run: bool,
174    platform: &str,
175    offline: bool,
176    attest: Vec<String>,
177) -> ExitCode {
178    use crate::verdict::{verdict_for, Verdict};
179    let store = Store::at(repo);
180    if !store.exists() {
181        eprintln!("error: no .evolving/ store here — run `ev init` first");
182        return ExitCode::FAILURE;
183    }
184    let files = match store.read_all() {
185        Ok(f) => f,
186        Err(e) => {
187            eprintln!("error: reading store: {e}");
188            return ExitCode::FAILURE;
189        }
190    };
191    let config = crate::config::read(&store);
192
193    // --run pass: for every live Test-bound ground that declares this platform, run the
194    // bound ref locally and append a receipt for it (one local run = one platform receipt).
195    if run {
196        for (_filename, raw) in &files {
197            let t = match crate::tick::from_value(raw) {
198                Ok(t) => t,
199                Err(_) => continue,
200            };
201            if t.status != "live" {
202                continue;
203            }
204            for g in &t.grounds {
205                if let Some(Check::Test {
206                    reference,
207                    counter_test,
208                    liveness,
209                    ..
210                }) = &g.check
211                {
212                    if liveness.platforms.iter().any(|p| p == platform) {
213                        // run the bound check
214                        match crate::runner::run_check(
215                            repo,
216                            reference,
217                            platform,
218                            config.green_exit_code,
219                        ) {
220                            Ok(mut rc) => {
221                                // prove falsifiability: the counter-test must produce the OPPOSITE
222                                // result. A harvested binding (counter_test None) skips this step,
223                                // leaving falsifiable None — the existing default.
224                                if let Some(counter_test) = counter_test {
225                                    if let Ok(ct) = crate::runner::run_check(
226                                        repo,
227                                        counter_test,
228                                        platform,
229                                        config.green_exit_code,
230                                    ) {
231                                        rc.falsifiable = Some(rc.result != ct.result);
232                                    }
233                                }
234                                if let Err(e) = crate::receipt::append(&store, &rc) {
235                                    eprintln!(
236                                        "warning: could not write receipt for {reference:?}: {e}"
237                                    );
238                                }
239                            }
240                            Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
241                        }
242                    }
243                }
244            }
245        }
246    }
247
248    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
249    let attest = if attest.is_empty() {
250        None
251    } else {
252        Some(attest)
253    };
254    let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
255    let mut rows: Vec<String> = Vec::new();
256    let mut any_not_green = false;
257    // Harvested-binding honesty debt: N test bindings carry no counter-test (counter_test None) out
258    // of M total test bindings. Surfaced as a trailing line so the missing falsifiability proof is
259    // never silent — the verdict itself stays honest (a harvested green/red reads exactly as it ran).
260    let mut total_test_bindings = 0usize;
261    let mut harvested_unproven = 0usize;
262
263    for (filename, raw) in &files {
264        let t = match crate::tick::from_value(raw) {
265            Ok(t) => t,
266            Err(_) => continue, // ev verify owns schema errors; check skips unparsable ticks
267        };
268        if t.status != "live" {
269            continue;
270        }
271        let mut verdicts = Vec::with_capacity(t.grounds.len());
272        for g in &t.grounds {
273            // Receipts are read only for Test-bound grounds; person/unbound need none.
274            let receipts = match &g.check {
275                Some(Check::Test { reference, .. }) => {
276                    crate::receipt::read_for(&store, reference).unwrap_or_default()
277                }
278                _ => Vec::new(),
279            };
280            // verdict_for returns NotApplicable for any non-Test ground.
281            let ts = triggered_since(repo, g, &receipts);
282            let mut v = verdict_for(g, &receipts, &ctx, ts);
283            // LOCK 1 (gate-time, structural): a C/D-jurisdiction (detect-only) decision is
284            // structurally ungateable — map ANY not-green verdict to the non-gating Memo BEFORE the
285            // any_not_green writer below, so it can never flip --exit-on-red. Remapping every
286            // not-green at once is more robust than threading Memo through each gate site.
287            if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
288                && !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt)
289            {
290                v = Verdict::Memo;
291            }
292            if !matches!(
293                v,
294                Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
295            ) {
296                any_not_green = true;
297            }
298            // Only Test-bound grounds appear in the printed set and the gate.
299            if let Some(Check::Test { counter_test, .. }) = &g.check {
300                total_test_bindings += 1;
301                let harvested = counter_test.is_none();
302                let mut detail = match &v {
303                    Verdict::NotRun { missing_platforms } => {
304                        format!("missing: {}", missing_platforms.join(", "))
305                    }
306                    Verdict::Stale { reason } => reason.clone(),
307                    _ => latest_ran_at(&receipts)
308                        .map(|ts| format!("ran {ts}"))
309                        .unwrap_or_else(|| "no receipt".into()),
310                };
311                // A harvested binding carries no counter-test, so its falsifiability was never
312                // proven; annotate the row honestly. The verdict is UNCHANGED — a passing harvested
313                // test still reads green (pass-green), a failing one still reads red (a real gate).
314                if harvested {
315                    harvested_unproven += 1;
316                    detail = format!("harvested — falsifiability not proven; {detail}");
317                    crate::events::append(&store, "harvested", Some(&t.id), Some(v.label()));
318                }
319                rows.push(format!(
320                    "{}\t{filename}\t{:?}\t({detail})",
321                    v.label(),
322                    g.claim
323                ));
324                crate::events::append(&store, "check", Some(&t.id), Some(v.label()));
325            }
326            verdicts.push((g, v));
327        }
328        // The per-host verdict-cache read contract for this tick (a hook reads it without shelling check).
329        let _ = crate::state::write_state(
330            &store,
331            &t.id,
332            &verdicts,
333            &config.staleness_ref,
334            ctx.live_origin_sha.as_deref(),
335        );
336    }
337
338    if rows.is_empty() {
339        println!("no test-bound grounds to check");
340    } else {
341        for r in &rows {
342            println!("{r}");
343        }
344        // The harvested-binding debt: how many of the test bindings have no counter-test (so their
345        // falsifiability is unproven). Pointed at `ev guard`, which is how a counter-test is added.
346        if harvested_unproven > 0 {
347            println!(
348                "harvested-unproven: {harvested_unproven} of {total_test_bindings} test bindings have no counter-test (run ev guard to add one)"
349            );
350        }
351        if !run {
352            // under --run the verdict itself carries falsifiability (an `unproven` row is a
353            // counter-test that did not flip); without it, point at the proof step rather than
354            // implying one already happened.
355            println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
356        }
357    }
358    if exit_on_red && any_not_green {
359        return ExitCode::FAILURE;
360    }
361    ExitCode::SUCCESS
362}
363
364/// The parsed `ev migrate` invocation (built in main.rs from the clap subcommand).
365pub struct MigrateArgs {
366    pub sources: Vec<String>,
367    pub dry_run: bool,
368    pub reconcile: bool,
369    pub against: Option<String>,
370    pub blame: Option<String>,
371    pub bind_check: Option<String>,
372    pub platforms: Vec<String>,
373    pub triggered_by: Vec<String>,
374    pub surfaces: Vec<String>,
375    pub verified_at_sha: Option<String>,
376    pub jurisdiction_map: Option<String>,
377}
378
379/// Read a `--jurisdiction-map` file into a `source_key -> bucket` map. Each non-blank, non-`#` line is
380/// exactly two whitespace-separated tokens `<source_key> <bucket>`; every bucket is validated against
381/// the closed A/B/C/D vocabulary so an out-of-vocab bucket (or a malformed line) is a hard error that
382/// names the offending line. jurisdiction is non-hashed, so the map only adds a detect-only tag — it
383/// never moves a tick id. An absent path yields an empty map (every record imports untagged).
384fn parse_jurisdiction_map(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
385    let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
386    let mut map = std::collections::HashMap::new();
387    for line in text.lines() {
388        let l = line.trim();
389        if l.is_empty() || l.starts_with('#') {
390            continue;
391        }
392        let mut tokens = l.split_whitespace();
393        match (tokens.next(), tokens.next(), tokens.next()) {
394            (Some(key), Some(bucket), None) => {
395                crate::tick::validate_jurisdiction(bucket)
396                    .map_err(|e| format!("jurisdiction-map line {l:?}: {e}"))?;
397                map.insert(key.to_string(), bucket.to_string());
398            }
399            _ => {
400                return Err(format!(
401                    "jurisdiction-map line {l:?}: expected `<source_key> <bucket>`"
402                ))
403            }
404        }
405    }
406    Ok(map)
407}
408
409/// Read a `<kind>:<path>` source spec, dispatch to the matching pure extractor, and return the
410/// extracted records. The kind names the substrate format; the path is read from disk here (the
411/// extractors themselves stay pure `&str -> Vec<MigrationRecord>`).
412fn extract_source(spec: &str) -> Result<Vec<crate::migrate::MigrationRecord>, String> {
413    let (kind, path) = spec
414        .split_once(':')
415        .ok_or_else(|| format!("--source expects <kind>:<path>, got {spec:?}"))?;
416    let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
417    let recs = match kind {
418        "gitlog" => crate::migrate::extract_gitlog(&text),
419        "to-human" => crate::migrate::extract_to_human(&text),
420        "decisions-immutable" => crate::migrate::extract_decisions_immutable(&text),
421        "escalation" => crate::migrate::extract_escalation(&text),
422        other => {
423            return Err(format!(
424                "unknown source kind {other:?} (expected gitlog | to-human | decisions-immutable | escalation)"
425            ))
426        }
427    };
428    Ok(recs)
429}
430
431pub fn migrate(repo: &Path, a: MigrateArgs) -> ExitCode {
432    // --bind-check: harvest one existing test as a (counter-test-less) bound check and print it.
433    if let Some(selector) = &a.bind_check {
434        let sha = match crate::capture::resolve_sha(repo, &a.verified_at_sha) {
435            Ok(s) => s,
436            Err(e) => {
437                eprintln!("error: {e}");
438                return ExitCode::FAILURE;
439            }
440        };
441        match crate::migrate::bind_check(
442            selector.clone(),
443            sha,
444            a.platforms.clone(),
445            a.triggered_by.clone(),
446            a.surfaces.clone(),
447        ) {
448            Ok(Check::Test {
449                reference,
450                liveness,
451                ..
452            }) => {
453                println!(
454                    "harvested check (falsifiability not proven; no counter-test): {reference:?} on [{}] triggered-by [{}] surface [{}]",
455                    liveness.platforms.join(", "),
456                    liveness.triggered_by.join(", "),
457                    liveness.surfaces.join(", ")
458                );
459                return ExitCode::SUCCESS;
460            }
461            Ok(_) => unreachable!("bind_check yields a Test check"),
462            Err(e) => {
463                eprintln!("error: {e}");
464                return ExitCode::FAILURE;
465            }
466        }
467    }
468
469    // --reconcile --against <src>: join the source against the store and report the buckets.
470    if a.reconcile {
471        let against = match &a.against {
472            Some(s) => s,
473            None => {
474                eprintln!("error: --reconcile requires --against <kind>:<path>");
475                return ExitCode::FAILURE;
476            }
477        };
478        let recs = match extract_source(against) {
479            Ok(r) => r,
480            Err(e) => {
481                eprintln!("error: {e}");
482                return ExitCode::FAILURE;
483            }
484        };
485        match crate::migrate::reconcile(repo, &recs) {
486            Ok(rep) => {
487                println!(
488                    "reconcile: in-both {}, source-only {} (the capture gap), store-only {}, un-keyable {}",
489                    rep.in_both, rep.source_only, rep.store_only, rep.un_keyable
490                );
491                return ExitCode::SUCCESS;
492            }
493            Err(e) => {
494                eprintln!("error: {e}");
495                return ExitCode::FAILURE;
496            }
497        }
498    }
499
500    // The default action: backfill every --source into the ledger (idempotent).
501    if a.sources.is_empty() {
502        eprintln!("error: ev migrate needs at least one --source <kind>:<path> (or --reconcile / --bind-check)");
503        return ExitCode::FAILURE;
504    }
505    let mut records = Vec::new();
506    for spec in &a.sources {
507        match extract_source(spec) {
508            Ok(mut r) => records.append(&mut r),
509            Err(e) => {
510                eprintln!("error: {e}");
511                return ExitCode::FAILURE;
512            }
513        }
514    }
515    // An omitted --jurisdiction-map ⇒ an empty map ⇒ every record imports untagged (prior behavior).
516    let jurisdiction_map = match &a.jurisdiction_map {
517        Some(path) => match parse_jurisdiction_map(path) {
518            Ok(m) => m,
519            Err(e) => {
520                eprintln!("error: {e}");
521                return ExitCode::FAILURE;
522            }
523        },
524        None => std::collections::HashMap::new(),
525    };
526    match crate::migrate::backfill(
527        repo,
528        records,
529        a.blame.as_deref(),
530        &jurisdiction_map,
531        a.dry_run,
532    ) {
533        Ok(s) => {
534            if !a.dry_run {
535                crate::events::append(&Store::at(repo), "migrate", None, None);
536            }
537            println!(
538                "{}imported {}, skipped {}, re-linked {}, {} source-only gap(s)",
539                if a.dry_run { "(dry-run) " } else { "" },
540                s.imported,
541                s.skipped,
542                s.relinked,
543                s.source_only_gaps
544            );
545            ExitCode::SUCCESS
546        }
547        Err(e) => {
548            eprintln!("error: {e}");
549            ExitCode::FAILURE
550        }
551    }
552}
553
554pub fn why(repo: &Path, selector: &str) -> ExitCode {
555    let store = Store::at(repo);
556    if !store.exists() {
557        eprintln!("error: no .evolving/ store here — run `ev init` first");
558        return ExitCode::FAILURE;
559    }
560    let files = match store.read_all() {
561        Ok(f) => f,
562        Err(e) => {
563            eprintln!("error: reading store: {e}");
564            return ExitCode::FAILURE;
565        }
566    };
567    let mut found = false;
568    for (filename, raw) in &files {
569        let t = match crate::tick::from_value(raw) {
570            Ok(t) => t,
571            Err(_) => continue,
572        };
573        if t.status != "live" {
574            continue;
575        }
576        for g in &t.grounds {
577            if let Some(Check::Test { reference, .. }) = &g.check {
578                if reference.as_str() == selector {
579                    found = true;
580                    println!(
581                        "{filename}\t{:?}\tguards: {:?} ({})",
582                        t.decision, g.claim, g.supports
583                    );
584                }
585            }
586        }
587    }
588    if !found {
589        eprintln!("{selector:?} guards nothing");
590        return ExitCode::FAILURE;
591    }
592    ExitCode::SUCCESS
593}
594
595/// List every decision in the ledger: id, status, decision (sorted by id, deterministic).
596pub fn list(repo: &Path) -> ExitCode {
597    let store = Store::at(repo);
598    if !store.exists() {
599        eprintln!("error: no .evolving/ store here — run `ev init` first");
600        return ExitCode::FAILURE;
601    }
602    let files = match store.read_all() {
603        Ok(f) => f,
604        Err(e) => {
605            eprintln!("error: reading store: {e}");
606            return ExitCode::FAILURE;
607        }
608    };
609    // One pre-rendered line per tick, keyed by id so the output is deterministic. The bookkeeping
610    // tags (authority, jurisdiction, round_id) are appended inline when present — same one-line shape as show.
611    let mut rows: Vec<String> = files
612        .iter()
613        .map(|(name, raw)| {
614            let line = match crate::tick::from_value(raw) {
615                Ok(t) => {
616                    let mut l = format!("{name}\t{}\t{:?}", t.status, t.decision);
617                    if let Some(a) = &t.authority {
618                        l.push_str(&format!("\tauthority={a}"));
619                    }
620                    if let Some(j) = &t.jurisdiction {
621                        l.push_str(&format!("\tjurisdiction={j}"));
622                    }
623                    if let Some(r) = &t.round_id {
624                        l.push_str(&format!("\tround_id={r}"));
625                    }
626                    l
627                }
628                Err(_) => format!("{name}\t?\t\"<unparseable>\""),
629            };
630            line
631        })
632        .collect();
633    rows.sort();
634    if rows.is_empty() {
635        println!("no decisions yet");
636        return ExitCode::SUCCESS;
637    }
638    for line in &rows {
639        println!("{line}");
640    }
641    ExitCode::SUCCESS
642}
643
644/// A decision is "load-bearing" iff any of its grounds closes a road (`supports` starts with
645/// `"rejected:"`). Those are the rulings a fresh agent must not re-walk, so they pin above the cap.
646/// Detectable straight from the tick — 0-network, no receipts, no git.
647fn load_bearing(t: &Tick) -> bool {
648    t.grounds
649        .iter()
650        .any(|g| g.supports.starts_with("rejected:"))
651}
652
653/// Boot-read: the live user-ruled decisions and the roads they rejected. A near-zero-cost,
654/// 0-network read (read_all only; no git, no receipts) for a fresh agent to load the
655/// decisions it must respect and the options it must not re-propose. Load-bearing rulings
656/// (those that closed a road) sort FIRST — pinned above the cap regardless of recency — then
657/// by recency (held_since), then id. Capped to the effective limit, with a remainder footer
658/// that counts how many hidden rulings closed a road so the elision stays visible.
659pub fn brief(repo: &Path, limit: Option<usize>) -> ExitCode {
660    let store = Store::at(repo);
661    if !store.exists() {
662        eprintln!("error: no .evolving/ store here — run `ev init` first");
663        return ExitCode::FAILURE;
664    }
665    let files = match store.read_all() {
666        Ok(f) => f,
667        Err(e) => {
668            eprintln!("error: reading store: {e}");
669            return ExitCode::FAILURE;
670        }
671    };
672    // The flag overrides config; 0 (here or in config) means "show all".
673    let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
674    // Keep only live, user-ruled decisions; carry the id so output is deterministic.
675    let mut kept: Vec<(String, Tick)> = files
676        .iter()
677        .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
678        .filter(|(_, t)| t.status == "live" && t.authority.as_deref() == Some("user-ruled"))
679        .collect();
680    let lb = load_bearing;
681    // Load-bearing first (true > false, so descending pins them), then most-recent-first by
682    // held_since, then id descending — all deterministic.
683    kept.sort_by(|a, b| {
684        lb(&b.1)
685            .cmp(&lb(&a.1))
686            .then(b.1.held_since.cmp(&a.1.held_since))
687            .then(b.0.cmp(&a.0))
688    });
689    if kept.is_empty() {
690        println!("no user-ruled decisions");
691        return ExitCode::SUCCESS;
692    }
693    let total = kept.len();
694    // 0 means "show all"; otherwise cap at the limit (never past the end).
695    let n = if limit == 0 { total } else { limit.min(total) };
696    // Count load-bearing rulings about to be elided, before we truncate the shown set.
697    let dropped_lb = kept[n..].iter().filter(|(_, t)| lb(t)).count();
698    kept.truncate(n);
699    for (_id, t) in &kept {
700        println!("{}  [user-ruled]", t.decision);
701        for g in &t.grounds {
702            if let Some(option) = g.supports.strip_prefix("rejected:") {
703                println!("  rejected {option}: {}", g.claim);
704            }
705        }
706    }
707    if total > n {
708        let dropped = total - n;
709        let lb_clause = if dropped_lb > 0 {
710            format!(", {dropped_lb} with rejected roads")
711        } else {
712            String::new()
713        };
714        println!("… {dropped} more user-ruled decision(s){lb_clause} — `ev list` for all");
715    }
716    ExitCode::SUCCESS
717}
718
719/// Show the decision lineage from HEAD back to genesis (newest first).
720pub fn log(repo: &Path) -> ExitCode {
721    let store = Store::at(repo);
722    if !store.exists() {
723        eprintln!("error: no .evolving/ store here — run `ev init` first");
724        return ExitCode::FAILURE;
725    }
726    let mut id = match store.read_head() {
727        Ok(h) => h,
728        Err(e) => {
729            eprintln!("error: reading HEAD: {e}");
730            return ExitCode::FAILURE;
731        }
732    };
733    if id.is_empty() {
734        println!("no decisions yet");
735        return ExitCode::SUCCESS;
736    }
737    let mut seen = std::collections::HashSet::new();
738    while !id.is_empty() {
739        if !seen.insert(id.clone()) {
740            break; // cycle guard (a content-addressed chain can't cycle, but never loop)
741        }
742        match store.read_tick(&id) {
743            Ok(Some(t)) => {
744                println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
745                id = t.parent_id;
746            }
747            Ok(None) => {
748                eprintln!("warning: {id} not found (broken lineage)");
749                break;
750            }
751            Err(e) => {
752                eprintln!("error: reading {id}: {e}");
753                return ExitCode::FAILURE;
754            }
755        }
756    }
757    ExitCode::SUCCESS
758}
759
760pub fn reopen(repo: &Path, id: &str) -> ExitCode {
761    let store = Store::at(repo);
762    let tick = match store.read_tick(id) {
763        Ok(Some(t)) => t,
764        Ok(None) => {
765            eprintln!("error: no tick with id {id}");
766            return ExitCode::FAILURE;
767        }
768        Err(e) => {
769            eprintln!("error: reading {id}: {e}");
770            return ExitCode::FAILURE;
771        }
772    };
773    let config = crate::config::read(&store);
774    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
775    let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
776
777    crate::events::append(&store, "reopen", Some(id), None);
778    println!("decision {}: {:?}", tick.id, tick.decision);
779    if !tick.observe.is_empty() {
780        println!("observe: {:?}", tick.observe);
781    }
782    if let Some(a) = &tick.authority {
783        println!("authority: {a}");
784    }
785    if let Some(j) = &tick.jurisdiction {
786        println!("jurisdiction: {j}");
787    }
788    if let Some(r) = &tick.round_id {
789        println!("round_id: {r}");
790    }
791    for g in &tick.grounds {
792        match &g.check {
793            Some(Check::Test {
794                reference,
795                verified_at_sha,
796                ..
797            }) => {
798                let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
799                let ts = triggered_since(repo, g, &receipts);
800                let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
801                let now = v.label();
802                let short = &verified_at_sha[..verified_at_sha.len().min(8)];
803                println!(
804                    "  [{}] {:?} — test {:?} frozen@{short} now: {now}",
805                    g.supports, g.claim, reference
806                );
807            }
808            Some(Check::Person { reference }) => {
809                println!("  [{}] {:?} — person {:?}", g.supports, g.claim, reference);
810            }
811            None => {
812                println!("  [{}] {:?}", g.supports, g.claim);
813            }
814        }
815    }
816    ExitCode::SUCCESS
817}
818
819/// Reproduce the two frozen golden vectors; non-zero if either id drifts.
820fn self_test_golden() -> ExitCode {
821    let genesis = Tick {
822        id: String::new(),
823        parent_id: "".into(),
824        observe: "evaluating retrieval backend".into(),
825        decision: "freeze the retrieval schema for v2".into(),
826        grounds: vec![
827            Ground {
828                claim: "team still wants a frozen schema".into(),
829                supports: "chosen".into(),
830                check: Some(Check::Person {
831                    reference: "Q3 infra review".into(),
832                }),
833            },
834            Ground {
835                claim: "pgvector would lock our schema".into(),
836                supports: "rejected:pgvector".into(),
837                check: None,
838            },
839        ],
840        status: "live".into(),
841        held_since: "".into(),
842        blame: "Wang Yu".into(),
843        authority: None,
844        jurisdiction: None,
845        round_id: None,
846    };
847    let case1 = Tick {
848        id: String::new(),
849        parent_id: "7b21f0a4c8de".into(),
850        observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
851        decision: "restore-safety counter DB-backed; reject Redis".into(),
852        grounds: vec![
853            Ground {
854                claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
855                supports: "chosen".into(),
856                check: Some(Check::Test {
857                    reference: "pytest tests/test_redis_absent.py".into(),
858                    verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
859                    counter_test: Some(
860                        "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
861                    ),
862                    liveness: Liveness {
863                        platforms: vec!["linux-ci".into()],
864                        triggered_by: vec!["pyproject.toml".into()],
865                        surfaces: vec!["pyproject-deps".into()],
866                    },
867                }),
868            },
869            Ground {
870                claim: "team still wants 0-Redis posture".into(),
871                supports: "chosen".into(),
872                check: Some(Check::Person {
873                    reference: "Q3 infra review".into(),
874                }),
875            },
876            Ground {
877                claim: "Redis would add a new infra dependency".into(),
878                supports: "rejected:Redis".into(),
879                check: None,
880            },
881        ],
882        status: "live".into(),
883        held_since: "".into(),
884        blame: "Wang Yu".into(),
885        authority: None,
886        jurisdiction: None,
887        round_id: None,
888    };
889    // A harvested binding: case1's first ground with counter_test omitted (None). Pins that
890    // omit-on-None keeps every harvested id byte-stable — moving it would mean the payload changed.
891    let mut harvested = case1.clone();
892    if let Some(Check::Test { counter_test, .. }) = &mut harvested.grounds[0].check {
893        *counter_test = None;
894    }
895    let mut ok = true;
896    for (name, t, want) in [
897        ("genesis", &genesis, "e2b337f53a1f"),
898        ("case1", &case1, "638c47b0c9dd"),
899        ("harvested", &harvested, "0cf784b51331"),
900    ] {
901        let got = compute_id(t);
902        let pass = got == want;
903        ok &= pass;
904        println!(
905            "{} {name}: {got} (want {want})",
906            if pass { "✓" } else { "✗" }
907        );
908    }
909    if ok {
910        ExitCode::SUCCESS
911    } else {
912        ExitCode::FAILURE
913    }
914}