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