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