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