Skip to main content

ev/
capture.rs

1//! `ev decide` — walk the trailing args left-to-right into a draft, validate, append a child.
2use crate::canonical::compute_id;
3use crate::store::Store;
4use crate::tick::{Check, Ground, Tick};
5use std::path::Path;
6use std::process::Command;
7
8#[derive(Default)]
9struct DraftGround {
10    claim: String,
11    supports: String, // "chosen" | "rejected:<opt>"
12    revisit: Option<String>,
13    test_ref: Option<String>,
14    counter_test: Option<String>,
15    platforms: Vec<String>,
16    triggered_by: Vec<String>,
17    surfaces: Vec<String>,
18}
19
20fn need(args: &[String], i: usize, flag: &str) -> Result<String, String> {
21    args.get(i + 1)
22        .cloned()
23        .ok_or(format!("{flag} requires a value"))
24}
25
26fn last<'a>(g: &'a mut [DraftGround], flag: &str) -> Result<&'a mut DraftGround, String> {
27    g.last_mut()
28        .ok_or(format!("{flag} has no preceding --assume/--reject ground"))
29}
30
31/// Resolve the declared author: --blame, else `git config user.name`.
32pub(crate) fn resolve_blame(repo: &Path, blame_override: Option<String>) -> Result<String, String> {
33    if let Some(b) = blame_override {
34        let b = b.trim();
35        if b.is_empty() {
36            return Err("--blame must be non-empty".into());
37        }
38        return Ok(b.to_string());
39    }
40    let out = Command::new("git")
41        .arg("config")
42        .arg("user.name")
43        .current_dir(repo)
44        .output()
45        .map_err(|e| format!("cannot run git: {e}"))?;
46    let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
47    if name.is_empty() {
48        return Err("no author: pass --blame, or set git config user.name".into());
49    }
50    Ok(name)
51}
52
53pub(crate) fn resolve_sha(repo: &Path, sha_override: &Option<String>) -> Result<String, String> {
54    let sha = match sha_override {
55        Some(s) => s.trim().to_string(),
56        None => {
57            let out = std::process::Command::new("git")
58                .args(["rev-parse", "HEAD"])
59                .current_dir(repo)
60                .output()
61                .map_err(|e| format!("cannot run git: {e}"))?;
62            if !out.status.success() {
63                return Err(
64                    "cannot resolve verified_at_sha (not a git repo?) — pass --verified-at-sha"
65                        .into(),
66                );
67            }
68            String::from_utf8_lossy(&out.stdout).trim().to_string()
69        }
70    };
71    if !crate::tick::is_40_lower_hex(&sha) {
72        return Err(format!("verified_at_sha must be 40 lowercase hex: {sha}"));
73    }
74    Ok(sha)
75}
76
77fn t_grounds_text(grounds: &[Ground]) -> Vec<String> {
78    grounds.iter().map(|g| g.claim.clone()).collect()
79}
80
81/// One `git show -s --format=<fmt> <commit>` field, run in `repo`. Returns the trimmed
82/// stdout, or an error if git can't resolve the commit (the caller maps this to a clear message).
83fn git_show(repo: &Path, fmt: &str, commit: &str) -> Result<String, String> {
84    let out = Command::new("git")
85        .args(["show", "-s", fmt, commit])
86        .current_dir(repo)
87        .output()
88        .map_err(|e| format!("cannot run git: {e}"))?;
89    if !out.status.success() {
90        return Err(format!("decide: cannot read commit {commit}"));
91    }
92    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
93}
94
95/// The commit ENVELOPE we are allowed to seed from: subject (the decision text), author name
96/// (the default blame), and any `Refs #<n>` provenance lines from the body. The body is scanned
97/// ONLY for Refs lines — never parsed for grounds (those stay human-authored via --assume/--reject).
98struct Envelope {
99    subject: String,
100    author: String,
101    refs: Vec<String>,
102}
103
104/// The closed set of authoring roles a commit subject may declare, leading + `:`.
105const SUBJECT_ROLES: &[&str] = &["Dev", "QA", "Product", "Mac", "User"];
106
107/// The canonical role declared by a leading `<Role>:` prefix on the subject, if any
108/// (case-insensitive match against the closed vocabulary). The subject is otherwise untouched.
109fn subject_role(subject: &str) -> Option<&'static str> {
110    let head = subject.split_whitespace().next()?;
111    let word = head.strip_suffix(':')?;
112    SUBJECT_ROLES
113        .iter()
114        .find(|r| r.eq_ignore_ascii_case(word))
115        .copied()
116}
117
118/// Every `#<digits>` / `R<digits>` provenance token found in the subject, in order — the
119/// issue + round-id references a commit subject may carry (`re-milestone #1194 R2415`).
120fn subject_refs(subject: &str) -> Vec<String> {
121    subject
122        .split_whitespace()
123        .filter(|tok| {
124            let rest = tok
125                .strip_prefix('#')
126                .or_else(|| tok.strip_prefix('R'))
127                .or_else(|| tok.strip_prefix('r'));
128            matches!(rest, Some(d) if !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
129        })
130        .map(|t| t.to_string())
131        .collect()
132}
133
134fn read_envelope(repo: &Path, commit: &str) -> Result<Envelope, String> {
135    let subject = git_show(repo, "--format=%s", commit)?;
136    let author = git_show(repo, "--format=%an", commit)?;
137    let body = git_show(repo, "--format=%b", commit)?;
138    let refs = body
139        .lines()
140        .map(str::trim)
141        .filter(|l| l.starts_with("Refs #"))
142        .map(|l| l.to_string())
143        .collect();
144    Ok(Envelope {
145        subject,
146        author,
147        refs,
148    })
149}
150
151/// Validate a declared authority value against the closed vocabulary.
152pub(crate) fn validate_authority(val: &str) -> Result<(), String> {
153    if val == "user-ruled" || val == "agent-disposable" {
154        Ok(())
155    } else {
156        Err("authority must be user-ruled or agent-disposable".into())
157    }
158}
159
160/// The migrate-only harvested-binding constructor: build a `Check::Test` carrying NO counter-test
161/// (`counter_test: None`), as used when backfilling an existing `test_invariant_*`/`test_br_*` test
162/// whose falsifiability was never proven. You cannot half-harvest: the FULL 3-key liveness
163/// (≥1 platform, triggered-by, surface) stays MANDATORY — only the counter-test is dropped. There is
164/// no `--counter-test` flag on this path; the decide (capture.rs) and `ev guard` (guard.rs) paths
165/// stay byte-for-byte strict and still reject a vacuous binding. The honesty debt (the missing
166/// falsifiability proof) is surfaced later at `ev check`, never hidden.
167pub fn harvested_test_check(
168    reference: String,
169    verified_at_sha: String,
170    platforms: Vec<String>,
171    triggered_by: Vec<String>,
172    surfaces: Vec<String>,
173) -> Result<Check, String> {
174    use crate::tick::Liveness;
175    if reference.trim().is_empty() {
176        return Err("a harvested binding requires a non-empty test reference".into());
177    }
178    if !crate::tick::is_40_lower_hex(&verified_at_sha) {
179        return Err(format!(
180            "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
181        ));
182    }
183    if platforms.is_empty() || triggered_by.is_empty() || surfaces.is_empty() {
184        return Err(
185            "a harvested binding requires at least one platform, triggered-by, and surface (no half-harvest)"
186                .into(),
187        );
188    }
189    Ok(Check::Test {
190        reference,
191        verified_at_sha,
192        counter_test: None, // harvested: falsifiability not yet proven
193        liveness: Liveness {
194            platforms,
195            triggered_by,
196            surfaces,
197        },
198    })
199}
200
201/// An assembled, validated decision ready to be appended to the ledger — the single shape both
202/// `ev decide` (capture.rs) and `ev migrate` (migrate.rs) hand to `append`. It carries exactly the
203/// hashed payload (observe, decision, grounds) plus the bookkeeping fields; `append` owns the one
204/// compute_id / write_tick / R3-lint path so neither caller can fork the hashing.
205pub struct Decision {
206    pub observe: String,
207    pub decision: String,
208    pub grounds: Vec<Ground>,
209    pub blame: String,
210    pub authority: Option<String>,
211    pub jurisdiction: Option<String>,
212    pub source_ref: Option<serde_json::Value>,
213    pub provenance: Option<String>,
214}
215
216/// THE one place a decision becomes a tick: R3-lint the free text, read HEAD as the parent, stamp
217/// `held_since`, build the Tick, compute its content-addressed id, and write+advance HEAD. `ev decide`
218/// and `ev migrate` BOTH funnel through here so there is a single hashing path — a golden id can only
219/// move if this function moves (guarded by golden_vectors + the capture/migrate tests). The caller is
220/// responsible for having already resolved blame and validated the grounds.
221pub fn append(repo: &Path, d: Decision) -> Result<Tick, String> {
222    for field in std::iter::once(d.decision.clone())
223        .chain(std::iter::once(d.observe.clone()))
224        .chain(t_grounds_text(&d.grounds))
225    {
226        for verb in crate::lint::r3_self_evolve(&field) {
227            eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
228        }
229    }
230    let store = Store::at(repo);
231    if !store.exists() {
232        return Err("no .evolving/ store here — run `ev init` first".into());
233    }
234    let parent_id = store
235        .read_head()
236        .map_err(|e| format!("reading HEAD: {e}"))?;
237    let held_since = time::OffsetDateTime::now_utc()
238        .format(&time::format_description::well_known::Rfc3339)
239        .map_err(|e| format!("timestamp: {e}"))?;
240    let mut t = Tick {
241        id: String::new(),
242        parent_id,
243        observe: d.observe,
244        decision: d.decision,
245        grounds: d.grounds,
246        status: "live".into(),
247        held_since,
248        blame: d.blame,
249        authority: d.authority,
250        jurisdiction: d.jurisdiction,
251        source_ref: d.source_ref,
252        provenance: d.provenance,
253    };
254    t.id = compute_id(&t);
255    store
256        .write_tick(&t)
257        .map_err(|e| format!("writing tick: {e}"))?;
258    Ok(t)
259}
260
261fn build_ground(
262    repo: &Path,
263    d: DraftGround,
264    sha_override: &Option<String>,
265    authority: Option<&str>,
266) -> Result<Ground, String> {
267    use crate::tick::Liveness;
268    if d.claim.is_empty() {
269        return Err("ground claim is empty".into());
270    }
271    // A road-not-taken is closed: it never carries a human re-check (you do not schedule someone to
272    // re-confirm a non-choice). This stays a hard refusal regardless of authority.
273    if d.supports.starts_with("rejected:") && d.revisit.is_some() {
274        return Err("a road-not-taken (rejected) ground cannot carry a human re-check".into());
275    }
276    // 0.1.8 tripwire: a rejected road MAY carry a falsifiable test that trips when the closed road is
277    // re-walked — but ONLY when a human deliberately ruled the road closed (--authority user-ruled).
278    // The counter-test stays REQUIRED via the shared strict path below (no harvested rejected-road
279    // tripwire). HONESTY: this binds only a STRUCTURAL token (the test/counter-test grep a real
280    // artifact); a PROSE re-walk with no token (e.g. #1194's milestone re-assignment) has nothing to
281    // bind and STAYS surface-only — the tripwire does not and cannot catch it. An agent cannot author
282    // a gating tripwire: --authority is declared, not verified (the banked signing boundary), and the
283    // gate-path LOCK 3 excludes agent-proposed from gating.
284    if d.supports.starts_with("rejected:")
285        && d.test_ref.is_some()
286        && authority != Some("user-ruled")
287    {
288        return Err(
289            "a rejected road can carry a tripwire test only when the decision is --authority user-ruled"
290                .into(),
291        );
292    }
293    if d.revisit.is_some() && d.test_ref.is_some() {
294        return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
295    }
296    let has_test_fields = d.counter_test.is_some()
297        || !d.platforms.is_empty()
298        || !d.triggered_by.is_empty()
299        || !d.surfaces.is_empty();
300    let check = match (d.test_ref, d.revisit) {
301        (Some(reference), _) => {
302            let counter_test = d
303                .counter_test
304                .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
305            if counter_test.trim().is_empty() {
306                // write/read symmetry: from_value rejects an empty counter_test, so the decide
307                // write path must too — never persist a tick its own parser would refuse.
308                return Err("a test binding requires --counter-test (no vacuous binding)".into());
309            }
310            if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
311                return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
312            }
313            let verified_at_sha = resolve_sha(repo, sha_override)?;
314            Some(Check::Test {
315                reference,
316                verified_at_sha,
317                counter_test: Some(counter_test),
318                liveness: Liveness {
319                    platforms: d.platforms,
320                    triggered_by: d.triggered_by,
321                    surfaces: d.surfaces,
322                },
323            })
324        }
325        (None, Some(when)) => {
326            if has_test_fields {
327                return Err(
328                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
329                        .into(),
330                );
331            }
332            Some(Check::Person { reference: when })
333        }
334        (None, None) => {
335            if has_test_fields {
336                return Err(
337                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
338                        .into(),
339                );
340            }
341            None
342        }
343    };
344    Ok(Ground {
345        claim: d.claim,
346        supports: d.supports,
347        check,
348    })
349}
350
351pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
352    let mut observe = String::new();
353    let mut blame_override: Option<String> = None;
354    let mut sha_override: Option<String> = None;
355    let mut authority: Option<String> = None;
356    let mut jurisdiction: Option<String> = None;
357    let mut source_ref: Option<serde_json::Value> = None;
358    let mut from_git: Option<String> = None;
359    let mut drafts: Vec<DraftGround> = Vec::new();
360    let mut i = 0;
361    while i < args.len() {
362        let flag = args[i].clone();
363        match flag.as_str() {
364            "--from-git" => {
365                from_git = Some(need(args, i, &flag)?);
366            }
367            "--observe" => {
368                observe = need(args, i, &flag)?;
369            }
370            "--blame" => {
371                blame_override = Some(need(args, i, &flag)?);
372            }
373            "--verified-at-sha" => {
374                sha_override = Some(need(args, i, &flag)?);
375            }
376            "--authority" => {
377                let v = need(args, i, &flag)?;
378                validate_authority(&v)?;
379                authority = Some(v);
380            }
381            "--jurisdiction" => {
382                let v = need(args, i, &flag)?;
383                crate::tick::validate_jurisdiction(&v)?;
384                jurisdiction = Some(v);
385            }
386            "--source-ref" => {
387                // a durable, non-hashed, opaque source identity ev never interprets. On the interactive
388                // path it is a plain string; the canonical intake additionally accepts a structured object.
389                let v = need(args, i, &flag)?;
390                if v.is_empty() {
391                    return Err("--source-ref needs a non-empty value".into());
392                }
393                source_ref = Some(serde_json::Value::String(v));
394            }
395            "--reject" => {
396                let v = need(args, i, &flag)?;
397                let (opt, why) = v
398                    .split_once(':')
399                    .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
400                let (opt, why) = (opt.trim(), why.trim());
401                if opt.is_empty() || why.is_empty() {
402                    return Err("--reject needs non-empty <option> and <why>".into());
403                }
404                drafts.push(DraftGround {
405                    claim: why.into(),
406                    supports: format!("rejected:{opt}"),
407                    ..Default::default()
408                });
409            }
410            "--assume" => {
411                let claim = need(args, i, &flag)?;
412                drafts.push(DraftGround {
413                    claim,
414                    supports: "chosen".into(),
415                    ..Default::default()
416                });
417            }
418            "--revisit" => {
419                last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
420            }
421            "--assume-test" => {
422                last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
423            }
424            "--counter-test" => {
425                last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
426            }
427            "--on-platform" => {
428                let v = need(args, i, &flag)?;
429                last(&mut drafts, &flag)?.platforms.push(v);
430            }
431            "--triggered-by" => {
432                let v = need(args, i, &flag)?;
433                last(&mut drafts, &flag)?.triggered_by.push(v);
434            }
435            "--surface" => {
436                let v = need(args, i, &flag)?;
437                last(&mut drafts, &flag)?.surfaces.push(v);
438            }
439            other => return Err(format!("decide: unknown flag {other}")),
440        }
441        i += 2;
442    }
443
444    // Decision source: exactly one of {a positional decision, --from-git}. When --from-git is
445    // used, the decision text is the commit subject, the default blame is the commit author, and
446    // any `Refs #<n>` body lines are appended to observe as provenance (grounds stay human-authored).
447    let (decision, observe) = match (decision, &from_git) {
448        (Some(_), Some(_)) => {
449            return Err("decide: decision given twice (positional and --from-git)".into())
450        }
451        (None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
452        (Some(d), None) => (d.to_string(), observe),
453        (None, Some(commit)) => {
454            let env = read_envelope(repo, commit)?;
455            // A leading `<Role>:` on the subject declares the author (unless --blame overrides);
456            // otherwise the default blame is the commit author. The subject is left untouched.
457            if blame_override.is_none() {
458                blame_override = Some(match subject_role(&env.subject) {
459                    Some(role) => role.to_string(),
460                    None => env.author,
461                });
462            }
463            // Provenance from the subject's own #issue / R<round> tokens, plus body Refs lines.
464            let observe = std::iter::once(observe)
465                .chain(subject_refs(&env.subject))
466                .chain(env.refs)
467                .filter(|s| !s.is_empty())
468                .collect::<Vec<_>>()
469                .join(" ");
470            (env.subject, observe)
471        }
472    };
473    if decision.trim().is_empty() {
474        return Err("decision text is empty".into());
475    }
476    let blame = resolve_blame(repo, blame_override)?;
477    let mut grounds = Vec::new();
478    for d in drafts {
479        // authority (parsed + validated above) is decision-global; the rejected-road tripwire lift
480        // gates on it, so thread it in. It is non-hashed, so gating on it never moves a tick id.
481        grounds.push(build_ground(repo, d, &sha_override, authority.as_deref())?);
482    }
483    // The single hashing path: decide hands its assembled Decision to the shared append, exactly as
484    // migrate does, so there is one compute_id / write_tick / R3-lint site (no per-caller fork).
485    append(
486        repo,
487        Decision {
488            observe,
489            decision: decision.to_string(),
490            grounds,
491            blame,
492            authority,
493            jurisdiction,
494            source_ref,
495            // Fresh authorship is hard-stamped human-now (the absent default); decide takes no
496            // provenance from the caller, so an importer can never launder a forbidden op as imported.
497            provenance: None,
498        },
499    )
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::tick::Check;
506
507    fn repo() -> std::path::PathBuf {
508        use std::sync::atomic::{AtomicU64, Ordering};
509        static N: AtomicU64 = AtomicU64::new(0);
510        let p = std::env::temp_dir().join(format!(
511            "ev-capture-{}-{}",
512            std::process::id(),
513            N.fetch_add(1, Ordering::Relaxed)
514        ));
515        let _ = std::fs::remove_dir_all(&p);
516        std::fs::create_dir_all(&p).unwrap();
517        Store::at(&p).init().unwrap();
518        p
519    }
520    fn s(v: &[&str]) -> Vec<String> {
521        v.iter().map(|x| x.to_string()).collect()
522    }
523
524    #[test]
525    fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
526        // given: a store and decide args with a chosen+revisit ground and a rejected road
527        let r = repo();
528
529        // when: the decision is captured
530        let t = run(
531            &r,
532            Some("build our own retrieval; reject pgvector"),
533            &s(&[
534                "--observe",
535                "evaluating backend",
536                "--assume",
537                "team has bandwidth long-term",
538                "--revisit",
539                "Q3 review",
540                "--reject",
541                "pgvector: would lock our schema",
542                "--blame",
543                "Wang Yu",
544            ]),
545        )
546        .expect("ok");
547
548        // then: both grounds, the person check, the rejected support, blame, and HEAD all hold
549        assert_eq!(t.grounds.len(), 2);
550        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
551        assert_eq!(t.grounds[1].supports, "rejected:pgvector");
552        assert_eq!(t.blame, "Wang Yu");
553        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
554    }
555
556    #[test]
557    fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
558        // given: a store
559        let r = repo();
560
561        // when: run records a decision
562        run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
563
564        // then: the stored HEAD tick's held_since is non-empty and parses as RFC 3339
565        let head = Store::at(&r).read_head().unwrap();
566        let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
567        assert!(!tick.held_since.is_empty());
568        time::OffsetDateTime::parse(
569            &tick.held_since,
570            &time::format_description::well_known::Rfc3339,
571        )
572        .expect("held_since parses as RFC 3339");
573    }
574
575    #[test]
576    fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
577        // given: a store and decide args with a padded --blame
578        let r = repo();
579
580        // when: the decision is captured
581        let t = run(
582            &r,
583            Some("d"),
584            &s(&["--assume", "c", "--blame", "  Wang Yu  "]),
585        )
586        .expect("ok");
587
588        // then: the stored blame is trimmed
589        assert_eq!(t.blame, "Wang Yu");
590    }
591
592    #[test]
593    fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
594        // given: a store and decide args binding one ground to both --revisit and --assume-test
595        let r = repo();
596
597        // when: the decision is captured
598        let e = run(
599            &r,
600            Some("d"),
601            &s(&[
602                "--assume",
603                "c",
604                "--revisit",
605                "Q3",
606                "--assume-test",
607                "pytest x",
608                "--blame",
609                "Wang Yu",
610            ]),
611        );
612
613        // then: it is refused
614        assert!(e.is_err());
615    }
616
617    #[test]
618    fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_absent() {
619        // given: a store and decide args attaching a test tripwire to a --reject road, NO --authority
620        let r = repo();
621
622        // when: the decision is captured
623        let e = run(
624            &r,
625            Some("d"),
626            &s(&[
627                "--reject",
628                "pgvector: would lock our schema",
629                "--assume-test",
630                "pytest x",
631                "--counter-test",
632                "ct",
633                "--on-platform",
634                "linux-ci",
635                "--triggered-by",
636                "f",
637                "--surface",
638                "s",
639                "--verified-at-sha",
640                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
641                "--blame",
642                "Wang Yu",
643            ]),
644        );
645
646        // then: it is refused — a rejected-road tripwire is allowed only when --authority user-ruled
647        assert!(e.is_err());
648    }
649
650    #[test]
651    fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_agent_disposable() {
652        // given: the same tripwire but declared agent-disposable (not a human's closed-road ruling)
653        let r = repo();
654
655        // when: the decision is captured
656        let e = run(
657            &r,
658            Some("d"),
659            &s(&[
660                "--reject",
661                "pgvector: would lock our schema",
662                "--assume-test",
663                "pytest x",
664                "--counter-test",
665                "ct",
666                "--on-platform",
667                "linux-ci",
668                "--triggered-by",
669                "f",
670                "--surface",
671                "s",
672                "--verified-at-sha",
673                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
674                "--authority",
675                "agent-disposable",
676                "--blame",
677                "Wang Yu",
678            ]),
679        );
680
681        // then: refused — only a user-ruled closed road earns a gating tripwire
682        assert!(e.is_err());
683    }
684
685    #[test]
686    fn decide_should_accept_a_tripwire_on_a_rejected_road_when_authority_is_user_ruled() {
687        // given: a user-ruled decision binding a falsifiable tripwire to the road it closed
688        let r = repo();
689
690        // when: captured with --authority user-ruled + a full test binding on the --reject road
691        let t = run(
692            &r,
693            Some("keep Redis out"),
694            &s(&[
695                "--reject",
696                "Redis: a new infra dependency",
697                "--assume-test",
698                "! grep -q redis pyproject.toml",
699                "--counter-test",
700                "grep -q redis pyproject.toml",
701                "--on-platform",
702                "linux-ci",
703                "--triggered-by",
704                "pyproject.toml",
705                "--surface",
706                "pyproject-deps",
707                "--verified-at-sha",
708                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
709                "--authority",
710                "user-ruled",
711                "--blame",
712                "Wang Yu",
713            ]),
714        )
715        .expect("a user-ruled rejected-road tripwire is allowed");
716
717        // then: the closed road carries the test tripwire
718        let g = t
719            .grounds
720            .iter()
721            .find(|g| g.supports.starts_with("rejected:"))
722            .expect("a rejected road");
723        assert!(
724            matches!(g.check, Some(Check::Test { .. })),
725            "the closed road carries a tripwire"
726        );
727    }
728
729    #[test]
730    fn decide_should_refuse_a_user_ruled_rejected_road_tripwire_when_the_counter_test_is_missing() {
731        // given: a user-ruled rejected-road tripwire with NO --counter-test (no falsifiability proof)
732        let r = repo();
733
734        // when: the decision is captured
735        let e = run(
736            &r,
737            Some("keep Redis out"),
738            &s(&[
739                "--reject",
740                "Redis: a new infra dependency",
741                "--assume-test",
742                "! grep -q redis pyproject.toml",
743                "--on-platform",
744                "linux-ci",
745                "--triggered-by",
746                "pyproject.toml",
747                "--surface",
748                "pyproject-deps",
749                "--verified-at-sha",
750                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
751                "--authority",
752                "user-ruled",
753                "--blame",
754                "Wang Yu",
755            ]),
756        );
757
758        // then: refused — a tripwire stays falsifiable; counter-test is required even for a closed road
759        assert!(e.is_err());
760    }
761
762    #[test]
763    fn decide_should_still_refuse_a_revisit_on_a_rejected_road_even_when_user_ruled() {
764        // given: a user-ruled --reject road with a --revisit human re-check (not a test tripwire)
765        let r = repo();
766
767        // when: the decision is captured
768        let e = run(
769            &r,
770            Some("keep Redis out"),
771            &s(&[
772                "--reject",
773                "Redis: a new infra dependency",
774                "--revisit",
775                "Q3 infra review",
776                "--authority",
777                "user-ruled",
778                "--blame",
779                "Wang Yu",
780            ]),
781        );
782
783        // then: refused — a closed road is not re-confirmed by a human re-check; only a structural tripwire
784        assert!(e.is_err());
785    }
786
787    #[test]
788    fn decide_should_error_when_there_is_no_store() {
789        // given: a directory with no .evolving/ store
790        let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
791        let _ = std::fs::remove_dir_all(&p);
792        std::fs::create_dir_all(&p).unwrap();
793
794        // when: a decision is captured there
795        let e = run(&p, Some("d"), &s(&["--blame", "x"]));
796
797        // then: it errors
798        assert!(e.is_err());
799    }
800
801    #[test]
802    fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
803        // given: a store and decide args with a fully specified test binding plus a rejected road
804        let r = repo();
805
806        // when: the decision is captured
807        let t = run(
808            &r,
809            Some("restore-safety counter DB-backed; reject Redis"),
810            &s(&[
811                "--assume",
812                "Argus introduces no Redis; multi-pod coord via existing DB",
813                "--assume-test",
814                "pytest tests/test_redis_absent.py",
815                "--counter-test",
816                "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
817                "--on-platform",
818                "linux-ci",
819                "--triggered-by",
820                "pyproject.toml",
821                "--surface",
822                "pyproject-deps",
823                "--verified-at-sha",
824                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
825                "--reject",
826                "Redis: a new infra dependency",
827                "--blame",
828                "Wang Yu",
829            ]),
830        )
831        .expect("ok");
832
833        // then: the first ground carries a fully populated test check
834        match &t.grounds[0].check {
835            Some(Check::Test {
836                reference,
837                counter_test,
838                liveness,
839                verified_at_sha,
840            }) => {
841                assert_eq!(reference, "pytest tests/test_redis_absent.py");
842                assert!(counter_test
843                    .as_deref()
844                    .is_some_and(|c| c.contains("flips_red")));
845                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
846                assert_eq!(verified_at_sha.len(), 40);
847            }
848            _ => panic!("expected a test check"),
849        }
850    }
851
852    #[test]
853    fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
854        // given: a store and a test binding missing --counter-test
855        let r = repo();
856
857        // when: the decision is captured
858        let e = run(
859            &r,
860            Some("d"),
861            &s(&[
862                "--assume",
863                "c",
864                "--assume-test",
865                "pytest x",
866                "--on-platform",
867                "linux-ci",
868                "--triggered-by",
869                "f",
870                "--surface",
871                "s",
872                "--verified-at-sha",
873                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
874                "--blame",
875                "Wang Yu",
876            ]),
877        );
878
879        // then: it is rejected
880        assert!(e.is_err());
881    }
882
883    #[test]
884    fn decide_should_reject_a_test_binding_when_the_counter_test_is_empty() {
885        // given: a store and a test binding whose --counter-test is empty
886        let r = repo();
887
888        // when: the decision is captured with an empty counter-test
889        let e = run(
890            &r,
891            Some("d"),
892            &s(&[
893                "--assume",
894                "c",
895                "--assume-test",
896                "pytest x",
897                "--counter-test",
898                "",
899                "--on-platform",
900                "linux-ci",
901                "--triggered-by",
902                "f",
903                "--surface",
904                "s",
905                "--verified-at-sha",
906                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
907                "--blame",
908                "Wang Yu",
909            ]),
910        );
911
912        // then: an empty counter-test is a vacuous binding — rejected at the write path too
913        assert!(e.is_err());
914    }
915
916    #[test]
917    fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
918        // given: a store and a test binding with no --verified-at-sha in a non-git dir
919        let r = repo();
920
921        // when: the decision is captured
922        let e = run(
923            &r,
924            Some("d"),
925            &s(&[
926                "--assume",
927                "c",
928                "--assume-test",
929                "pytest x",
930                "--counter-test",
931                "ct",
932                "--on-platform",
933                "linux-ci",
934                "--triggered-by",
935                "f",
936                "--surface",
937                "s",
938                "--blame",
939                "Wang Yu",
940            ]),
941        );
942
943        // then: it is rejected
944        assert!(e.is_err());
945    }
946
947    #[test]
948    fn migrate_bind_should_build_a_harvested_test_check_when_no_counter_test() {
949        // given: the migrate-only inputs — a ref, a sha, and the FULL 3-key liveness, but NO
950        // counter-test (you cannot half-harvest: liveness stays mandatory, falsifiability does not)
951        let check = harvested_test_check(
952            "pytest tests/test_invariant_no_redis.py".into(),
953            "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
954            vec!["linux-ci".into()],
955            vec!["pyproject.toml".into()],
956            vec!["pyproject-deps".into()],
957        )
958        .expect("the full liveness is present, so the harvested binding is well-formed");
959
960        // then: it is a Test check carrying counter_test None (harvested) with liveness intact
961        match check {
962            Check::Test {
963                reference,
964                counter_test,
965                liveness,
966                verified_at_sha,
967            } => {
968                assert_eq!(reference, "pytest tests/test_invariant_no_redis.py");
969                assert_eq!(counter_test, None); // harvested: falsifiability not yet proven
970                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
971                assert_eq!(liveness.triggered_by, vec!["pyproject.toml".to_string()]);
972                assert_eq!(liveness.surfaces, vec!["pyproject-deps".to_string()]);
973                assert_eq!(verified_at_sha.len(), 40);
974            }
975            _ => panic!("expected a harvested test check"),
976        }
977    }
978
979    #[test]
980    fn migrate_bind_should_reject_a_harvested_binding_when_a_liveness_key_is_missing() {
981        // given: the migrate-only inputs with an empty surfaces key (a half-harvest attempt)
982        let e = harvested_test_check(
983            "pytest x".into(),
984            "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
985            vec!["linux-ci".into()],
986            vec!["pyproject.toml".into()],
987            vec![], // no --surface: the 3-key liveness is incomplete
988        );
989
990        // then: it is rejected — harvesting drops the counter-test, never the liveness
991        assert!(e.is_err());
992    }
993
994    #[test]
995    fn decide_should_still_error_without_a_counter_test() {
996        // given: the migrate-only harvested path now exists; pin that the decide path is UNCHANGED
997        // — a `--assume-test` binding with full liveness but no --counter-test STILL errors (the
998        // strict capture.rs guard stays byte-for-byte; harvesting is migrate-only, not decide-wide).
999        let r = repo();
1000
1001        // when: a decision binds a test with full liveness but omits --counter-test
1002        let e = run(
1003            &r,
1004            Some("d"),
1005            &s(&[
1006                "--assume",
1007                "c",
1008                "--assume-test",
1009                "pytest x",
1010                "--on-platform",
1011                "linux-ci",
1012                "--triggered-by",
1013                "f",
1014                "--surface",
1015                "s",
1016                "--verified-at-sha",
1017                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
1018                "--blame",
1019                "Wang Yu",
1020            ]),
1021        );
1022
1023        // then: it errors — no vacuous binding on the decide path
1024        assert!(e.is_err());
1025    }
1026
1027    #[test]
1028    fn append_should_compute_the_frozen_genesis_id_when_given_the_genesis_decision() {
1029        // given: a store and the genesis decision assembled as a Decision (the SAME fields the
1030        // golden vector freezes) — proving decide + migrate share one compute_id / write_tick path.
1031        let r = repo();
1032        let d = Decision {
1033            observe: "evaluating retrieval backend".into(),
1034            decision: "freeze the retrieval schema for v2".into(),
1035            grounds: vec![
1036                Ground {
1037                    claim: "team still wants a frozen schema".into(),
1038                    supports: "chosen".into(),
1039                    check: Some(Check::Person {
1040                        reference: "Q3 infra review".into(),
1041                    }),
1042                },
1043                Ground {
1044                    claim: "pgvector would lock our schema".into(),
1045                    supports: "rejected:pgvector".into(),
1046                    check: None,
1047                },
1048            ],
1049            blame: "Wang Yu".into(),
1050            authority: None,
1051            jurisdiction: None,
1052            source_ref: None,
1053            provenance: None,
1054        };
1055
1056        // when: it is appended onto the empty store (genesis: parent_id == "")
1057        let t = append(&r, d).expect("ok");
1058
1059        // then: the content-addressed id matches the frozen genesis golden — the shared append
1060        // hashes byte-identically to the legacy decide tail (no golden drift from the refactor).
1061        assert_eq!(t.id, "e2b337f53a1f");
1062        assert_eq!(t.parent_id, "");
1063        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
1064    }
1065
1066    #[test]
1067    fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
1068        // given: a store inside a git repo with a configured author, and no --blame
1069        let r = repo();
1070        for a in [
1071            ["init"].as_slice(),
1072            ["config", "user.name", "Ada Lovelace"].as_slice(),
1073        ] {
1074            std::process::Command::new("git")
1075                .args(a)
1076                .current_dir(&r)
1077                .output()
1078                .unwrap();
1079        }
1080
1081        // when: a decision is captured without --blame
1082        let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
1083
1084        // then: blame is resolved from git config user.name
1085        assert_eq!(t.blame, "Ada Lovelace");
1086    }
1087}