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