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), None, 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), None, 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/// Roll-up significance for the one-per-tick check event: the tick's single event carries its WORST
209/// test-bound verdict, so the de-quintupled count keeps the catch visible. Every GATING verdict (the
210/// ones outside the `any_not_green` exclusion — red, silently-unbound, stale, not-run, unproven)
211/// outranks a non-gating outcome (green/exempt/memo/n-a), so a co-occurring green can never erase a
212/// gating fact's label. Facts only — this orders for the per-tick roll-up, it is not a score on the
213/// decision. (A stale hidden behind a worse verdict is separately surfaced via the masked_stale field.)
214fn verdict_rank(v: &crate::verdict::Verdict) -> u8 {
215    use crate::verdict::Verdict;
216    match v {
217        Verdict::Red | Verdict::GrayRed => 6,
218        // a gating mask-bypass (a touched trigger that was not re-selected) — must outrank green
219        Verdict::SilentlyUnbound => 5,
220        Verdict::Stale { .. } => 4,
221        Verdict::NotRun { .. } => 3,
222        Verdict::Unproven => 2,
223        Verdict::Memo => 1,
224        Verdict::Green | Verdict::Exempt | Verdict::NotApplicable => 0,
225    }
226}
227
228/// Roll up a tick's test-bound verdicts to the one per-tick check event: `(worst_event_label,
229/// masked_stale)`. The worst verdict (by `verdict_rank`) is the event's verdict — a strict `>` keeps
230/// the FIRST top-rank ground, so a stale sub-kind follows verdict_for's own precedence (sha → count →
231/// age), not ground order. `masked_stale` is the first stale sub-kind present ONLY when a worse verdict
232/// (red / silently-unbound, rank > stale's 4) hides it — so a drifted/disabled staleness_ref masking a
233/// real red never silently drops. None when no test-bound ground (the tick emits no check event).
234fn roll_up_check(verdicts: &[&crate::verdict::Verdict]) -> Option<(String, Option<String>)> {
235    use crate::verdict::Verdict;
236    let mut worst: Option<(u8, &Verdict)> = None;
237    let mut stale: Option<&Verdict> = None;
238    for &v in verdicts {
239        let rank = verdict_rank(v);
240        if worst.map_or(true, |(r, _)| rank > r) {
241            worst = Some((rank, v));
242        }
243        if stale.is_none() && matches!(v, Verdict::Stale { .. }) {
244            stale = Some(v);
245        }
246    }
247    worst.map(|(rank, v)| {
248        let masked = if rank > 4 {
249            stale.map(|s| s.event_label())
250        } else {
251            None
252        };
253        (v.event_label(), masked)
254    })
255}
256
257/// The evaluation context for one `ev check` / `ev reopen` invocation: the staleness reference
258/// (resolved per policy by the caller), the selected-list, the wall clock, and the staleness
259/// window. The I/O assembly lives here in the command layer so `verdict::verdict_for` stays pure.
260fn live_ctx(
261    store: &Store,
262    staleness_days: u64,
263    live_origin_sha: Option<String>,
264    attest: Option<Vec<String>>,
265) -> crate::verdict::Ctx {
266    crate::verdict::Ctx {
267        live_origin_sha,
268        selected: crate::selected::read(store).unwrap_or(None),
269        now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
270        staleness_secs: staleness_days as i64 * 86_400,
271        attest,
272    }
273}
274
275pub fn check(
276    repo: &Path,
277    exit_on_red: bool,
278    run: bool,
279    platform: &str,
280    offline: bool,
281    attest: Vec<String>,
282) -> ExitCode {
283    use crate::verdict::{verdict_for, Verdict};
284    let store = Store::at(repo);
285    if !store.exists() {
286        eprintln!("error: no .evolving/ store here — run `ev init` first");
287        return ExitCode::FAILURE;
288    }
289    let files = match store.read_all() {
290        Ok(f) => f,
291        Err(e) => {
292            eprintln!("error: reading store: {e}");
293            return ExitCode::FAILURE;
294        }
295    };
296    let config = crate::config::read(&store);
297
298    // --run pass: for every live Test-bound ground that declares this platform, run the
299    // bound ref locally and append a receipt for it (one local run = one platform receipt).
300    if run {
301        for (_filename, raw) in &files {
302            let t = match crate::tick::from_value(raw) {
303                Ok(t) => t,
304                Err(_) => continue,
305            };
306            if t.status != "live" {
307                continue;
308            }
309            for g in &t.grounds {
310                if let Some(Check::Test {
311                    reference,
312                    counter_test,
313                    liveness,
314                    ..
315                }) = &g.check
316                {
317                    if liveness.platforms.iter().any(|p| p == platform) {
318                        // run the bound check
319                        match crate::runner::run_check(
320                            repo,
321                            reference,
322                            platform,
323                            config.green_exit_code,
324                        ) {
325                            Ok(mut rc) => {
326                                // prove falsifiability: the counter-test must produce the OPPOSITE
327                                // result. A harvested binding (counter_test None) skips this step,
328                                // leaving falsifiable None — the existing default.
329                                if let Some(counter_test) = counter_test {
330                                    if let Ok(ct) = crate::runner::run_check(
331                                        repo,
332                                        counter_test,
333                                        platform,
334                                        config.green_exit_code,
335                                    ) {
336                                        rc.falsifiable = Some(rc.result != ct.result);
337                                    }
338                                }
339                                if let Err(e) = crate::receipt::append(&store, &rc) {
340                                    eprintln!(
341                                        "warning: could not write receipt for {reference:?}: {e}"
342                                    );
343                                }
344                            }
345                            Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
346                        }
347                    }
348                }
349            }
350        }
351    }
352
353    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
354    let attest = if attest.is_empty() {
355        None
356    } else {
357        Some(attest)
358    };
359    let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
360    let mut rows: Vec<String> = Vec::new();
361    let mut any_not_green = false;
362    // Harvested-binding honesty debt: N test bindings carry no counter-test (counter_test None) out
363    // of M total test bindings. Surfaced as a trailing line so the missing falsifiability proof is
364    // never silent — the verdict itself stays honest (a harvested green/red reads exactly as it ran).
365    let mut total_test_bindings = 0usize;
366    let mut harvested_unproven = 0usize;
367
368    for (filename, raw) in &files {
369        let t = match crate::tick::from_value(raw) {
370            Ok(t) => t,
371            Err(_) => continue, // ev verify owns schema errors; check skips unparsable ticks
372        };
373        if t.status != "live" {
374            continue;
375        }
376        let mut verdicts = Vec::with_capacity(t.grounds.len());
377        for g in &t.grounds {
378            // Receipts are read only for Test-bound grounds; person/unbound need none.
379            let receipts = match &g.check {
380                Some(Check::Test { reference, .. }) => {
381                    crate::receipt::read_for(&store, reference).unwrap_or_default()
382                }
383                _ => Vec::new(),
384            };
385            // verdict_for returns NotApplicable for any non-Test ground.
386            let ts = triggered_since(repo, g, &receipts);
387            let mut v = verdict_for(g, &receipts, &ctx, ts);
388            // LOCK 1 (gate-time, structural): a C/D-jurisdiction (detect-only) decision is
389            // structurally ungateable — map ANY not-green verdict to the non-gating Memo BEFORE the
390            // any_not_green writer below, so it can never flip --exit-on-red. Remapping every
391            // not-green at once is more robust than threading Memo through each gate site.
392            if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
393                && !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt)
394            {
395                v = Verdict::Memo;
396            }
397            if !matches!(
398                v,
399                Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
400            ) {
401                any_not_green = true;
402            }
403            // Only Test-bound grounds appear in the printed set and the gate.
404            if let Some(Check::Test { counter_test, .. }) = &g.check {
405                total_test_bindings += 1;
406                let harvested = counter_test.is_none();
407                let mut detail = match &v {
408                    Verdict::NotRun { missing_platforms } => {
409                        format!("missing: {}", missing_platforms.join(", "))
410                    }
411                    Verdict::Stale { reason, .. } => reason.clone(),
412                    _ => latest_ran_at(&receipts)
413                        .map(|ts| format!("ran {ts}"))
414                        .unwrap_or_else(|| "no receipt".into()),
415                };
416                // A harvested binding carries no counter-test, so its falsifiability was never
417                // proven; annotate the row honestly. The verdict is UNCHANGED — a passing harvested
418                // test still reads green (pass-green), a failing one still reads red (a real gate).
419                if harvested {
420                    harvested_unproven += 1;
421                    detail = format!("harvested — falsifiability not proven; {detail}");
422                    crate::events::append(
423                        &store,
424                        "harvested",
425                        Some(&t),
426                        Some(&v.event_label()),
427                        None,
428                    );
429                }
430                rows.push(format!(
431                    "{}\t{filename}\t{:?}\t({detail})",
432                    v.label(),
433                    g.claim
434                ));
435            }
436            verdicts.push((g, v));
437        }
438        // ONE check event per tick (the de-quintupled count): the worst test-bound verdict, plus a
439        // masked_stale companion when a worse verdict hides a stale ground (see `roll_up_check`).
440        let test_verdicts: Vec<&Verdict> = verdicts
441            .iter()
442            .filter(|(g, _)| matches!(g.check, Some(Check::Test { .. })))
443            .map(|(_, v)| v)
444            .collect();
445        if let Some((label, masked_stale)) = roll_up_check(&test_verdicts) {
446            crate::events::append(
447                &store,
448                "check",
449                Some(&t),
450                Some(&label),
451                masked_stale.as_deref(),
452            );
453        }
454        // The per-host verdict-cache read contract for this tick (a hook reads it without shelling check).
455        let _ = crate::state::write_state(
456            &store,
457            &t.id,
458            &verdicts,
459            &config.staleness_ref,
460            ctx.live_origin_sha.as_deref(),
461        );
462    }
463
464    if rows.is_empty() {
465        println!("no test-bound grounds to check");
466    } else {
467        for r in &rows {
468            println!("{r}");
469        }
470        // The harvested-binding debt: how many of the test bindings have no counter-test (so their
471        // falsifiability is unproven). Pointed at `ev guard`, which is how a counter-test is added.
472        if harvested_unproven > 0 {
473            println!(
474                "harvested-unproven: {harvested_unproven} of {total_test_bindings} test bindings have no counter-test (run ev guard to add one)"
475            );
476        }
477        if !run {
478            // under --run the verdict itself carries falsifiability (an `unproven` row is a
479            // counter-test that did not flip); without it, point at the proof step rather than
480            // implying one already happened.
481            println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
482        }
483    }
484    if exit_on_red && any_not_green {
485        return ExitCode::FAILURE;
486    }
487    ExitCode::SUCCESS
488}
489
490/// The parsed `ev migrate` invocation (built in main.rs from the clap subcommand).
491pub struct MigrateArgs {
492    pub sources: Vec<String>,
493    pub dry_run: bool,
494    pub reconcile: bool,
495    pub against: Option<String>,
496    pub blame: Option<String>,
497    pub bind_check: Option<String>,
498    pub platforms: Vec<String>,
499    pub triggered_by: Vec<String>,
500    pub surfaces: Vec<String>,
501    pub verified_at_sha: Option<String>,
502    pub jurisdiction_map: Option<String>,
503}
504
505/// Read a `--jurisdiction-map` file into a `source_key -> bucket` map. Each non-blank, non-`#` line is
506/// exactly two whitespace-separated tokens `<source_key> <bucket>`; every bucket is validated against
507/// the closed A/B/C/D vocabulary so an out-of-vocab bucket (or a malformed line) is a hard error that
508/// names the offending line. jurisdiction is non-hashed, so the map only adds a detect-only tag — it
509/// never moves a tick id. An absent path yields an empty map (every record imports untagged).
510fn parse_jurisdiction_map(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
511    let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
512    let mut map = std::collections::HashMap::new();
513    for line in text.lines() {
514        let l = line.trim();
515        if l.is_empty() || l.starts_with('#') {
516            continue;
517        }
518        let mut tokens = l.split_whitespace();
519        match (tokens.next(), tokens.next(), tokens.next()) {
520            (Some(key), Some(bucket), None) => {
521                crate::tick::validate_jurisdiction(bucket)
522                    .map_err(|e| format!("jurisdiction-map line {l:?}: {e}"))?;
523                map.insert(key.to_string(), bucket.to_string());
524            }
525            _ => {
526                return Err(format!(
527                    "jurisdiction-map line {l:?}: expected `<source_key> <bucket>`"
528                ))
529            }
530        }
531    }
532    Ok(map)
533}
534
535/// Read a `<kind>:<path>` source spec, dispatch to the matching pure extractor, and return the
536/// extracted records. The kind names the substrate format; the path is read from disk here (the
537/// extractors themselves stay pure `&str -> Vec<MigrationRecord>`).
538fn extract_source(spec: &str) -> Result<Vec<crate::migrate::MigrationRecord>, String> {
539    let (kind, path) = spec
540        .split_once(':')
541        .ok_or_else(|| format!("--source expects <kind>:<path>, got {spec:?}"))?;
542    let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
543    let recs = match kind {
544        // The format-neutral primary intake: a producer-owned adapter (or a live runner) emits the
545        // Canonical Decision Intake JSONL, re-validated through ev's read-path validators on the way in.
546        "canonical" => crate::migrate::canonical_records(&text)?,
547        "gitlog" => crate::migrate::extract_gitlog(&text),
548        "to-human" => crate::migrate::extract_to_human(&text),
549        "decisions-immutable" => crate::migrate::extract_decisions_immutable(&text),
550        "escalation" => crate::migrate::extract_escalation(&text),
551        other => {
552            return Err(format!(
553                "unknown source kind {other:?} (expected canonical | gitlog | to-human | decisions-immutable | escalation)"
554            ))
555        }
556    };
557    Ok(recs)
558}
559
560pub fn migrate(repo: &Path, a: MigrateArgs) -> ExitCode {
561    // --bind-check: harvest one existing test as a (counter-test-less) bound check and print it.
562    if let Some(selector) = &a.bind_check {
563        let sha = match crate::capture::resolve_sha(repo, &a.verified_at_sha) {
564            Ok(s) => s,
565            Err(e) => {
566                eprintln!("error: {e}");
567                return ExitCode::FAILURE;
568            }
569        };
570        match crate::migrate::bind_check(
571            selector.clone(),
572            sha,
573            a.platforms.clone(),
574            a.triggered_by.clone(),
575            a.surfaces.clone(),
576        ) {
577            Ok(Check::Test {
578                reference,
579                liveness,
580                ..
581            }) => {
582                println!(
583                    "harvested check (falsifiability not proven; no counter-test): {reference:?} on [{}] triggered-by [{}] surface [{}]",
584                    liveness.platforms.join(", "),
585                    liveness.triggered_by.join(", "),
586                    liveness.surfaces.join(", ")
587                );
588                return ExitCode::SUCCESS;
589            }
590            Ok(_) => unreachable!("bind_check yields a Test check"),
591            Err(e) => {
592                eprintln!("error: {e}");
593                return ExitCode::FAILURE;
594            }
595        }
596    }
597
598    // --reconcile --against <src>: join the source against the store and report the buckets.
599    if a.reconcile {
600        let against = match &a.against {
601            Some(s) => s,
602            None => {
603                eprintln!("error: --reconcile requires --against <kind>:<path>");
604                return ExitCode::FAILURE;
605            }
606        };
607        let recs = match extract_source(against) {
608            Ok(r) => r,
609            Err(e) => {
610                eprintln!("error: {e}");
611                return ExitCode::FAILURE;
612            }
613        };
614        match crate::migrate::reconcile(repo, &recs) {
615            Ok(rep) => {
616                println!(
617                    "reconcile: in-both {}, source-only {} (the capture gap), store-only {}, un-keyable {}",
618                    rep.in_both, rep.source_only, rep.store_only, rep.un_keyable
619                );
620                return ExitCode::SUCCESS;
621            }
622            Err(e) => {
623                eprintln!("error: {e}");
624                return ExitCode::FAILURE;
625            }
626        }
627    }
628
629    // The default action: backfill every --source into the ledger (idempotent).
630    if a.sources.is_empty() {
631        eprintln!("error: ev migrate needs at least one --source <kind>:<path> (or --reconcile / --bind-check)");
632        return ExitCode::FAILURE;
633    }
634    let mut records = Vec::new();
635    for spec in &a.sources {
636        match extract_source(spec) {
637            Ok(mut r) => records.append(&mut r),
638            Err(e) => {
639                eprintln!("error: {e}");
640                return ExitCode::FAILURE;
641            }
642        }
643    }
644    // An omitted --jurisdiction-map ⇒ an empty map ⇒ every record imports untagged (prior behavior).
645    let jurisdiction_map = match &a.jurisdiction_map {
646        Some(path) => match parse_jurisdiction_map(path) {
647            Ok(m) => m,
648            Err(e) => {
649                eprintln!("error: {e}");
650                return ExitCode::FAILURE;
651            }
652        },
653        None => std::collections::HashMap::new(),
654    };
655    match crate::migrate::backfill(
656        repo,
657        records,
658        a.blame.as_deref(),
659        &jurisdiction_map,
660        a.dry_run,
661    ) {
662        Ok(s) => {
663            if !a.dry_run {
664                crate::events::append(&Store::at(repo), "migrate", None, None, None);
665            }
666            println!(
667                "{}imported {}, skipped {}, re-linked {}, {} source-only gap(s){}",
668                if a.dry_run { "(dry-run) " } else { "" },
669                s.imported,
670                s.skipped,
671                s.relinked,
672                s.source_only_gaps,
673                if s.discrepancies > 0 {
674                    format!(", {} discrepancy(ies) — see above", s.discrepancies)
675                } else {
676                    String::new()
677                }
678            );
679            ExitCode::SUCCESS
680        }
681        Err(e) => {
682            eprintln!("error: {e}");
683            ExitCode::FAILURE
684        }
685    }
686}
687
688pub fn why(repo: &Path, selector: &str) -> ExitCode {
689    let store = Store::at(repo);
690    if !store.exists() {
691        eprintln!("error: no .evolving/ store here — run `ev init` first");
692        return ExitCode::FAILURE;
693    }
694    let files = match store.read_all() {
695        Ok(f) => f,
696        Err(e) => {
697            eprintln!("error: reading store: {e}");
698            return ExitCode::FAILURE;
699        }
700    };
701    let mut found = false;
702    for (filename, raw) in &files {
703        let t = match crate::tick::from_value(raw) {
704            Ok(t) => t,
705            Err(_) => continue,
706        };
707        if t.status != "live" {
708            continue;
709        }
710        for g in &t.grounds {
711            if let Some(Check::Test { reference, .. }) = &g.check {
712                if reference.as_str() == selector {
713                    found = true;
714                    println!(
715                        "{filename}\t{:?}\tguards: {:?} ({})",
716                        t.decision, g.claim, g.supports
717                    );
718                }
719            }
720        }
721    }
722    if !found {
723        eprintln!("{selector:?} guards nothing");
724        return ExitCode::FAILURE;
725    }
726    ExitCode::SUCCESS
727}
728
729/// List every decision in the ledger: id, status, decision (sorted by id, deterministic).
730pub fn list(repo: &Path) -> ExitCode {
731    let store = Store::at(repo);
732    if !store.exists() {
733        eprintln!("error: no .evolving/ store here — run `ev init` first");
734        return ExitCode::FAILURE;
735    }
736    let files = match store.read_all() {
737        Ok(f) => f,
738        Err(e) => {
739            eprintln!("error: reading store: {e}");
740            return ExitCode::FAILURE;
741        }
742    };
743    // One pre-rendered line per tick, keyed by id so the output is deterministic. The bookkeeping
744    // tags (authority, jurisdiction, source_ref) are appended inline when present — same one-line shape as show.
745    // Collapse each corrective lineage to its current state (an `ev correct` child supersedes the
746    // stale tick it re-tags); unparseable ticks are always shown (verify flags them) since they have
747    // no decision identity to supersede.
748    let mut parsed: Vec<(String, Tick)> = Vec::new();
749    let mut rows: Vec<String> = Vec::new();
750    for (name, raw) in &files {
751        match crate::tick::from_value(raw) {
752            Ok(t) => parsed.push((name.clone(), t)),
753            Err(_) => rows.push(format!("{name}\t?\t\"<unparseable>\"")),
754        }
755    }
756    for (name, t) in current_decisions(parsed) {
757        let mut l = format!("{name}\t{}\t{:?}", t.status, t.decision);
758        if let Some(a) = &t.authority {
759            l.push_str(&format!("\tauthority={a}"));
760        }
761        if let Some(j) = &t.jurisdiction {
762            l.push_str(&format!("\tjurisdiction={j}"));
763        }
764        if let Some(r) = &t.source_ref {
765            l.push_str(&format!("\tsource_ref={}", render_source_ref(r)));
766        }
767        rows.push(l);
768    }
769    rows.sort();
770    if rows.is_empty() {
771        println!("no decisions yet");
772        return ExitCode::SUCCESS;
773    }
774    for line in &rows {
775        println!("{line}");
776    }
777    ExitCode::SUCCESS
778}
779
780/// A decision is "load-bearing" iff any of its grounds closes a road (`supports` starts with
781/// `"rejected:"`). Those are the rulings a fresh agent must not re-walk, so they pin above the cap.
782/// Detectable straight from the tick — 0-network, no receipts, no git.
783fn load_bearing(t: &Tick) -> bool {
784    t.grounds
785        .iter()
786        .any(|g| g.supports.starts_with("rejected:"))
787}
788
789/// Boot-read: the live user-ruled decisions and the roads they rejected. A near-zero-cost,
790/// 0-network read (read_all only; no git, no receipts) for a fresh agent to load the
791/// decisions it must respect and the options it must not re-propose. Load-bearing rulings
792/// (those that closed a road) sort FIRST — pinned above the cap regardless of recency — then
793/// by recency (held_since), then id. Capped to the effective limit, with a remainder footer
794/// that counts how many hidden rulings closed a road so the elision stays visible.
795pub fn brief(repo: &Path, limit: Option<usize>) -> ExitCode {
796    let store = Store::at(repo);
797    if !store.exists() {
798        eprintln!("error: no .evolving/ store here — run `ev init` first");
799        return ExitCode::FAILURE;
800    }
801    let files = match store.read_all() {
802        Ok(f) => f,
803        Err(e) => {
804            eprintln!("error: reading store: {e}");
805            return ExitCode::FAILURE;
806        }
807    };
808    // The flag overrides config; 0 (here or in config) means "show all".
809    let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
810    // Collapse each corrective lineage to its current state BEFORE filtering, so an `ev correct` that
811    // (de)promotes authority is honored — then keep only live, user-ruled decisions.
812    let all: Vec<(String, Tick)> = files
813        .iter()
814        .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
815        .collect();
816    let mut kept: Vec<(String, Tick)> = current_decisions(all)
817        .into_iter()
818        .filter(|(_, t)| t.status == "live" && t.authority.as_deref() == Some("user-ruled"))
819        .collect();
820    let lb = load_bearing;
821    // Load-bearing first (true > false, so descending pins them), then most-recent-first by
822    // held_since, then id descending — all deterministic.
823    kept.sort_by(|a, b| {
824        lb(&b.1)
825            .cmp(&lb(&a.1))
826            .then(b.1.held_since.cmp(&a.1.held_since))
827            .then(b.0.cmp(&a.0))
828    });
829    if kept.is_empty() {
830        println!("no user-ruled decisions");
831        return ExitCode::SUCCESS;
832    }
833    let total = kept.len();
834    // 0 means "show all"; otherwise cap at the limit (never past the end).
835    let n = if limit == 0 { total } else { limit.min(total) };
836    // Count load-bearing rulings about to be elided, before we truncate the shown set.
837    let dropped_lb = kept[n..].iter().filter(|(_, t)| lb(t)).count();
838    kept.truncate(n);
839    for (_id, t) in &kept {
840        println!("{}  [user-ruled]", t.decision);
841        for g in &t.grounds {
842            if let Some(option) = g.supports.strip_prefix("rejected:") {
843                println!("  rejected {option}: {}", g.claim);
844            }
845        }
846    }
847    if total > n {
848        let dropped = total - n;
849        let lb_clause = if dropped_lb > 0 {
850            format!(", {dropped_lb} with rejected roads")
851        } else {
852            String::new()
853        };
854        println!("… {dropped} more user-ruled decision(s){lb_clause} — `ev list` for all");
855    }
856    ExitCode::SUCCESS
857}
858
859/// Show the decision lineage from HEAD back to genesis (newest first).
860pub fn log(repo: &Path) -> ExitCode {
861    let store = Store::at(repo);
862    if !store.exists() {
863        eprintln!("error: no .evolving/ store here — run `ev init` first");
864        return ExitCode::FAILURE;
865    }
866    let mut id = match store.read_head() {
867        Ok(h) => h,
868        Err(e) => {
869            eprintln!("error: reading HEAD: {e}");
870            return ExitCode::FAILURE;
871        }
872    };
873    if id.is_empty() {
874        println!("no decisions yet");
875        return ExitCode::SUCCESS;
876    }
877    let mut seen = std::collections::HashSet::new();
878    while !id.is_empty() {
879        if !seen.insert(id.clone()) {
880            break; // cycle guard (a content-addressed chain can't cycle, but never loop)
881        }
882        match store.read_tick(&id) {
883            Ok(Some(t)) => {
884                println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
885                id = t.parent_id;
886            }
887            Ok(None) => {
888                eprintln!("warning: {id} not found (broken lineage)");
889                break;
890            }
891            Err(e) => {
892                eprintln!("error: reading {id}: {e}");
893                return ExitCode::FAILURE;
894            }
895        }
896    }
897    ExitCode::SUCCESS
898}
899
900pub fn reopen(repo: &Path, id: &str) -> ExitCode {
901    let store = Store::at(repo);
902    let tick = match store.read_tick(id) {
903        Ok(Some(t)) => t,
904        Ok(None) => {
905            eprintln!("error: no tick with id {id}");
906            return ExitCode::FAILURE;
907        }
908        Err(e) => {
909            eprintln!("error: reading {id}: {e}");
910            return ExitCode::FAILURE;
911        }
912    };
913    let config = crate::config::read(&store);
914    let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
915    let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
916
917    crate::events::append(&store, "reopen", Some(&tick), None, None);
918    println!("decision {}: {:?}", tick.id, tick.decision);
919    if !tick.observe.is_empty() {
920        println!("observe: {:?}", tick.observe);
921    }
922    if let Some(a) = &tick.authority {
923        println!("authority: {a}");
924    }
925    if let Some(j) = &tick.jurisdiction {
926        println!("jurisdiction: {j}");
927    }
928    if let Some(r) = &tick.source_ref {
929        println!("source_ref: {}", render_source_ref(r));
930    }
931    for g in &tick.grounds {
932        match &g.check {
933            Some(Check::Test {
934                reference,
935                verified_at_sha,
936                ..
937            }) => {
938                let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
939                let ts = triggered_since(repo, g, &receipts);
940                let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
941                let now = v.label();
942                let short = &verified_at_sha[..verified_at_sha.len().min(8)];
943                println!(
944                    "  [{}] {:?} — test {:?} frozen@{short} now: {now}",
945                    g.supports, g.claim, reference
946                );
947            }
948            Some(Check::Person { reference }) => {
949                println!("  [{}] {:?} — person {:?}", g.supports, g.claim, reference);
950            }
951            None => {
952                println!("  [{}] {:?}", g.supports, g.claim);
953            }
954        }
955    }
956    ExitCode::SUCCESS
957}
958
959/// Reproduce the two frozen golden vectors; non-zero if either id drifts.
960fn self_test_golden() -> ExitCode {
961    let genesis = Tick {
962        id: String::new(),
963        parent_id: "".into(),
964        observe: "evaluating retrieval backend".into(),
965        decision: "freeze the retrieval schema for v2".into(),
966        grounds: vec![
967            Ground {
968                claim: "team still wants a frozen schema".into(),
969                supports: "chosen".into(),
970                check: Some(Check::Person {
971                    reference: "Q3 infra review".into(),
972                }),
973            },
974            Ground {
975                claim: "pgvector would lock our schema".into(),
976                supports: "rejected:pgvector".into(),
977                check: None,
978            },
979        ],
980        status: "live".into(),
981        held_since: "".into(),
982        blame: "Wang Yu".into(),
983        authority: None,
984        jurisdiction: None,
985        source_ref: None,
986        provenance: None,
987    };
988    let case1 = Tick {
989        id: String::new(),
990        parent_id: "7b21f0a4c8de".into(),
991        observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
992        decision: "restore-safety counter DB-backed; reject Redis".into(),
993        grounds: vec![
994            Ground {
995                claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
996                supports: "chosen".into(),
997                check: Some(Check::Test {
998                    reference: "pytest tests/test_redis_absent.py".into(),
999                    verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
1000                    counter_test: Some(
1001                        "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
1002                    ),
1003                    liveness: Liveness {
1004                        platforms: vec!["linux-ci".into()],
1005                        triggered_by: vec!["pyproject.toml".into()],
1006                        surfaces: vec!["pyproject-deps".into()],
1007                    },
1008                }),
1009            },
1010            Ground {
1011                claim: "team still wants 0-Redis posture".into(),
1012                supports: "chosen".into(),
1013                check: Some(Check::Person {
1014                    reference: "Q3 infra review".into(),
1015                }),
1016            },
1017            Ground {
1018                claim: "Redis would add a new infra dependency".into(),
1019                supports: "rejected:Redis".into(),
1020                check: None,
1021            },
1022        ],
1023        status: "live".into(),
1024        held_since: "".into(),
1025        blame: "Wang Yu".into(),
1026        authority: None,
1027        jurisdiction: None,
1028        source_ref: None,
1029        provenance: None,
1030    };
1031    // A harvested binding: case1's first ground with counter_test omitted (None). Pins that
1032    // omit-on-None keeps every harvested id byte-stable — moving it would mean the payload changed.
1033    let mut harvested = case1.clone();
1034    if let Some(Check::Test { counter_test, .. }) = &mut harvested.grounds[0].check {
1035        *counter_test = None;
1036    }
1037    let mut ok = true;
1038    for (name, t, want) in [
1039        ("genesis", &genesis, "e2b337f53a1f"),
1040        ("case1", &case1, "638c47b0c9dd"),
1041        ("harvested", &harvested, "0cf784b51331"),
1042    ] {
1043        let got = compute_id(t);
1044        let pass = got == want;
1045        ok &= pass;
1046        println!(
1047            "{} {name}: {got} (want {want})",
1048            if pass { "✓" } else { "✗" }
1049        );
1050    }
1051    if ok {
1052        ExitCode::SUCCESS
1053    } else {
1054        ExitCode::FAILURE
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::roll_up_check;
1061    use crate::verdict::{StaleKind, Verdict};
1062
1063    fn stale_sha() -> Verdict {
1064        Verdict::Stale {
1065            kind: StaleKind::Sha,
1066            reason: String::new(),
1067        }
1068    }
1069
1070    #[test]
1071    fn roll_up_check_should_emit_nothing_when_there_is_no_test_bound_ground() {
1072        // given: a tick with no test-bound verdicts -> no check event
1073        assert_eq!(roll_up_check(&[]), None);
1074    }
1075
1076    #[test]
1077    fn roll_up_check_should_carry_the_worst_verdict_red_over_green() {
1078        // given: a green ground and a red ground -> the event carries red, the catch stays visible
1079        let (g, r) = (Verdict::Green, Verdict::Red);
1080        assert_eq!(roll_up_check(&[&g, &r]), Some(("red".to_string(), None)));
1081    }
1082
1083    #[test]
1084    fn roll_up_check_should_let_a_gating_silently_unbound_outrank_a_co_occurring_green() {
1085        // given: a silently-unbound (gating mask-bypass) + a green ground -> su must win either order,
1086        // so a co-occurring green never erases the gating fact from the log
1087        let (su, g) = (Verdict::SilentlyUnbound, Verdict::Green);
1088        assert_eq!(
1089            roll_up_check(&[&su, &g]),
1090            Some(("silently-unbound".to_string(), None))
1091        );
1092        assert_eq!(
1093            roll_up_check(&[&g, &su]),
1094            Some(("silently-unbound".to_string(), None))
1095        );
1096    }
1097
1098    #[test]
1099    fn roll_up_check_should_carry_the_stale_sub_kind_when_stale_is_the_worst() {
1100        // given: a sha-stale ground alongside a not-run -> the verdict IS the stale sub-kind (visible)
1101        let (s, nr) = (
1102            stale_sha(),
1103            Verdict::NotRun {
1104                missing_platforms: vec!["p".to_string()],
1105            },
1106        );
1107        assert_eq!(
1108            roll_up_check(&[&s, &nr]),
1109            Some(("stale:sha".to_string(), None))
1110        );
1111    }
1112
1113    #[test]
1114    fn roll_up_check_should_surface_a_stale_masked_behind_a_red() {
1115        // given: a red ground hiding a sha-stale ground -> the event carries red AND the masked stale,
1116        // so a drifted/disabled staleness_ref masking a real red never silently drops
1117        let (r, s) = (Verdict::Red, stale_sha());
1118        assert_eq!(
1119            roll_up_check(&[&r, &s]),
1120            Some(("red".to_string(), Some("stale:sha".to_string())))
1121        );
1122    }
1123
1124    #[test]
1125    fn roll_up_check_should_emit_green_when_every_ground_is_green() {
1126        let (a, b) = (Verdict::Green, Verdict::Green);
1127        assert_eq!(roll_up_check(&[&a, &b]), Some(("green".to_string(), None)));
1128    }
1129}