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