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 round_id: Option<String>,
213}
214
215/// THE one place a decision becomes a tick: R3-lint the free text, read HEAD as the parent, stamp
216/// `held_since`, build the Tick, compute its content-addressed id, and write+advance HEAD. `ev decide`
217/// and `ev migrate` BOTH funnel through here so there is a single hashing path — a golden id can only
218/// move if this function moves (guarded by golden_vectors + the capture/migrate tests). The caller is
219/// responsible for having already resolved blame and validated the grounds.
220pub fn append(repo: &Path, d: Decision) -> Result<Tick, String> {
221    for field in std::iter::once(d.decision.clone())
222        .chain(std::iter::once(d.observe.clone()))
223        .chain(t_grounds_text(&d.grounds))
224    {
225        for verb in crate::lint::r3_self_evolve(&field) {
226            eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
227        }
228    }
229    let store = Store::at(repo);
230    if !store.exists() {
231        return Err("no .evolving/ store here — run `ev init` first".into());
232    }
233    let parent_id = store
234        .read_head()
235        .map_err(|e| format!("reading HEAD: {e}"))?;
236    let held_since = time::OffsetDateTime::now_utc()
237        .format(&time::format_description::well_known::Rfc3339)
238        .map_err(|e| format!("timestamp: {e}"))?;
239    let mut t = Tick {
240        id: String::new(),
241        parent_id,
242        observe: d.observe,
243        decision: d.decision,
244        grounds: d.grounds,
245        status: "live".into(),
246        held_since,
247        blame: d.blame,
248        authority: d.authority,
249        jurisdiction: d.jurisdiction,
250        round_id: d.round_id,
251    };
252    t.id = compute_id(&t);
253    store
254        .write_tick(&t)
255        .map_err(|e| format!("writing tick: {e}"))?;
256    Ok(t)
257}
258
259fn build_ground(
260    repo: &Path,
261    d: DraftGround,
262    sha_override: &Option<String>,
263) -> Result<Ground, String> {
264    use crate::tick::Liveness;
265    if d.claim.is_empty() {
266        return Err("ground claim is empty".into());
267    }
268    if d.supports.starts_with("rejected:") && (d.test_ref.is_some() || d.revisit.is_some()) {
269        return Err("a road-not-taken (rejected) ground cannot carry a check in 0.1.0 — reserved for a future rejection-rationale liveness feature".into());
270    }
271    if d.revisit.is_some() && d.test_ref.is_some() {
272        return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
273    }
274    let has_test_fields = d.counter_test.is_some()
275        || !d.platforms.is_empty()
276        || !d.triggered_by.is_empty()
277        || !d.surfaces.is_empty();
278    let check = match (d.test_ref, d.revisit) {
279        (Some(reference), _) => {
280            let counter_test = d
281                .counter_test
282                .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
283            if counter_test.trim().is_empty() {
284                // write/read symmetry: from_value rejects an empty counter_test, so the decide
285                // write path must too — never persist a tick its own parser would refuse.
286                return Err("a test binding requires --counter-test (no vacuous binding)".into());
287            }
288            if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
289                return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
290            }
291            let verified_at_sha = resolve_sha(repo, sha_override)?;
292            Some(Check::Test {
293                reference,
294                verified_at_sha,
295                counter_test: Some(counter_test),
296                liveness: Liveness {
297                    platforms: d.platforms,
298                    triggered_by: d.triggered_by,
299                    surfaces: d.surfaces,
300                },
301            })
302        }
303        (None, Some(when)) => {
304            if has_test_fields {
305                return Err(
306                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
307                        .into(),
308                );
309            }
310            Some(Check::Person { reference: when })
311        }
312        (None, None) => {
313            if has_test_fields {
314                return Err(
315                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
316                        .into(),
317                );
318            }
319            None
320        }
321    };
322    Ok(Ground {
323        claim: d.claim,
324        supports: d.supports,
325        check,
326    })
327}
328
329pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
330    let mut observe = String::new();
331    let mut blame_override: Option<String> = None;
332    let mut sha_override: Option<String> = None;
333    let mut authority: Option<String> = None;
334    let mut jurisdiction: Option<String> = None;
335    let mut round_id: Option<String> = None;
336    let mut from_git: Option<String> = None;
337    let mut drafts: Vec<DraftGround> = Vec::new();
338    let mut i = 0;
339    while i < args.len() {
340        let flag = args[i].clone();
341        match flag.as_str() {
342            "--from-git" => {
343                from_git = Some(need(args, i, &flag)?);
344            }
345            "--observe" => {
346                observe = need(args, i, &flag)?;
347            }
348            "--blame" => {
349                blame_override = Some(need(args, i, &flag)?);
350            }
351            "--verified-at-sha" => {
352                sha_override = Some(need(args, i, &flag)?);
353            }
354            "--authority" => {
355                let v = need(args, i, &flag)?;
356                validate_authority(&v)?;
357                authority = Some(v);
358            }
359            "--jurisdiction" => {
360                let v = need(args, i, &flag)?;
361                crate::tick::validate_jurisdiction(&v)?;
362                jurisdiction = Some(v);
363            }
364            "--round-id" => {
365                // a durable, non-hashed join/dedup key; non-empty-if-present, no other format constraint.
366                let v = need(args, i, &flag)?;
367                if v.is_empty() {
368                    return Err("--round-id needs a non-empty value".into());
369                }
370                round_id = Some(v);
371            }
372            "--reject" => {
373                let v = need(args, i, &flag)?;
374                let (opt, why) = v
375                    .split_once(':')
376                    .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
377                let (opt, why) = (opt.trim(), why.trim());
378                if opt.is_empty() || why.is_empty() {
379                    return Err("--reject needs non-empty <option> and <why>".into());
380                }
381                drafts.push(DraftGround {
382                    claim: why.into(),
383                    supports: format!("rejected:{opt}"),
384                    ..Default::default()
385                });
386            }
387            "--assume" => {
388                let claim = need(args, i, &flag)?;
389                drafts.push(DraftGround {
390                    claim,
391                    supports: "chosen".into(),
392                    ..Default::default()
393                });
394            }
395            "--revisit" => {
396                last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
397            }
398            "--assume-test" => {
399                last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
400            }
401            "--counter-test" => {
402                last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
403            }
404            "--on-platform" => {
405                let v = need(args, i, &flag)?;
406                last(&mut drafts, &flag)?.platforms.push(v);
407            }
408            "--triggered-by" => {
409                let v = need(args, i, &flag)?;
410                last(&mut drafts, &flag)?.triggered_by.push(v);
411            }
412            "--surface" => {
413                let v = need(args, i, &flag)?;
414                last(&mut drafts, &flag)?.surfaces.push(v);
415            }
416            other => return Err(format!("decide: unknown flag {other}")),
417        }
418        i += 2;
419    }
420
421    // Decision source: exactly one of {a positional decision, --from-git}. When --from-git is
422    // used, the decision text is the commit subject, the default blame is the commit author, and
423    // any `Refs #<n>` body lines are appended to observe as provenance (grounds stay human-authored).
424    let (decision, observe) = match (decision, &from_git) {
425        (Some(_), Some(_)) => {
426            return Err("decide: decision given twice (positional and --from-git)".into())
427        }
428        (None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
429        (Some(d), None) => (d.to_string(), observe),
430        (None, Some(commit)) => {
431            let env = read_envelope(repo, commit)?;
432            // A leading `<Role>:` on the subject declares the author (unless --blame overrides);
433            // otherwise the default blame is the commit author. The subject is left untouched.
434            if blame_override.is_none() {
435                blame_override = Some(match subject_role(&env.subject) {
436                    Some(role) => role.to_string(),
437                    None => env.author,
438                });
439            }
440            // Provenance from the subject's own #issue / R<round> tokens, plus body Refs lines.
441            let observe = std::iter::once(observe)
442                .chain(subject_refs(&env.subject))
443                .chain(env.refs)
444                .filter(|s| !s.is_empty())
445                .collect::<Vec<_>>()
446                .join(" ");
447            (env.subject, observe)
448        }
449    };
450    if decision.trim().is_empty() {
451        return Err("decision text is empty".into());
452    }
453    let blame = resolve_blame(repo, blame_override)?;
454    let mut grounds = Vec::new();
455    for d in drafts {
456        grounds.push(build_ground(repo, d, &sha_override)?);
457    }
458    // The single hashing path: decide hands its assembled Decision to the shared append, exactly as
459    // migrate does, so there is one compute_id / write_tick / R3-lint site (no per-caller fork).
460    append(
461        repo,
462        Decision {
463            observe,
464            decision: decision.to_string(),
465            grounds,
466            blame,
467            authority,
468            jurisdiction,
469            round_id,
470        },
471    )
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::tick::Check;
478
479    fn repo() -> std::path::PathBuf {
480        use std::sync::atomic::{AtomicU64, Ordering};
481        static N: AtomicU64 = AtomicU64::new(0);
482        let p = std::env::temp_dir().join(format!(
483            "ev-capture-{}-{}",
484            std::process::id(),
485            N.fetch_add(1, Ordering::Relaxed)
486        ));
487        let _ = std::fs::remove_dir_all(&p);
488        std::fs::create_dir_all(&p).unwrap();
489        Store::at(&p).init().unwrap();
490        p
491    }
492    fn s(v: &[&str]) -> Vec<String> {
493        v.iter().map(|x| x.to_string()).collect()
494    }
495
496    #[test]
497    fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
498        // given: a store and decide args with a chosen+revisit ground and a rejected road
499        let r = repo();
500
501        // when: the decision is captured
502        let t = run(
503            &r,
504            Some("build our own retrieval; reject pgvector"),
505            &s(&[
506                "--observe",
507                "evaluating backend",
508                "--assume",
509                "team has bandwidth long-term",
510                "--revisit",
511                "Q3 review",
512                "--reject",
513                "pgvector: would lock our schema",
514                "--blame",
515                "Wang Yu",
516            ]),
517        )
518        .expect("ok");
519
520        // then: both grounds, the person check, the rejected support, blame, and HEAD all hold
521        assert_eq!(t.grounds.len(), 2);
522        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
523        assert_eq!(t.grounds[1].supports, "rejected:pgvector");
524        assert_eq!(t.blame, "Wang Yu");
525        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
526    }
527
528    #[test]
529    fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
530        // given: a store
531        let r = repo();
532
533        // when: run records a decision
534        run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
535
536        // then: the stored HEAD tick's held_since is non-empty and parses as RFC 3339
537        let head = Store::at(&r).read_head().unwrap();
538        let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
539        assert!(!tick.held_since.is_empty());
540        time::OffsetDateTime::parse(
541            &tick.held_since,
542            &time::format_description::well_known::Rfc3339,
543        )
544        .expect("held_since parses as RFC 3339");
545    }
546
547    #[test]
548    fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
549        // given: a store and decide args with a padded --blame
550        let r = repo();
551
552        // when: the decision is captured
553        let t = run(
554            &r,
555            Some("d"),
556            &s(&["--assume", "c", "--blame", "  Wang Yu  "]),
557        )
558        .expect("ok");
559
560        // then: the stored blame is trimmed
561        assert_eq!(t.blame, "Wang Yu");
562    }
563
564    #[test]
565    fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
566        // given: a store and decide args binding one ground to both --revisit and --assume-test
567        let r = repo();
568
569        // when: the decision is captured
570        let e = run(
571            &r,
572            Some("d"),
573            &s(&[
574                "--assume",
575                "c",
576                "--revisit",
577                "Q3",
578                "--assume-test",
579                "pytest x",
580                "--blame",
581                "Wang Yu",
582            ]),
583        );
584
585        // then: it is refused
586        assert!(e.is_err());
587    }
588
589    #[test]
590    fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
591        // given: a store and decide args attaching an --assume-test to a --reject road
592        let r = repo();
593
594        // when: the decision is captured
595        let e = run(
596            &r,
597            Some("d"),
598            &s(&[
599                "--reject",
600                "pgvector: would lock our schema",
601                "--assume-test",
602                "pytest x",
603                "--counter-test",
604                "ct",
605                "--on-platform",
606                "linux-ci",
607                "--triggered-by",
608                "f",
609                "--surface",
610                "s",
611                "--verified-at-sha",
612                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
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_error_when_there_is_no_store() {
624        // given: a directory with no .evolving/ store
625        let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
626        let _ = std::fs::remove_dir_all(&p);
627        std::fs::create_dir_all(&p).unwrap();
628
629        // when: a decision is captured there
630        let e = run(&p, Some("d"), &s(&["--blame", "x"]));
631
632        // then: it errors
633        assert!(e.is_err());
634    }
635
636    #[test]
637    fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
638        // given: a store and decide args with a fully specified test binding plus a rejected road
639        let r = repo();
640
641        // when: the decision is captured
642        let t = run(
643            &r,
644            Some("restore-safety counter DB-backed; reject Redis"),
645            &s(&[
646                "--assume",
647                "Argus introduces no Redis; multi-pod coord via existing DB",
648                "--assume-test",
649                "pytest tests/test_redis_absent.py",
650                "--counter-test",
651                "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
652                "--on-platform",
653                "linux-ci",
654                "--triggered-by",
655                "pyproject.toml",
656                "--surface",
657                "pyproject-deps",
658                "--verified-at-sha",
659                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
660                "--reject",
661                "Redis: a new infra dependency",
662                "--blame",
663                "Wang Yu",
664            ]),
665        )
666        .expect("ok");
667
668        // then: the first ground carries a fully populated test check
669        match &t.grounds[0].check {
670            Some(Check::Test {
671                reference,
672                counter_test,
673                liveness,
674                verified_at_sha,
675            }) => {
676                assert_eq!(reference, "pytest tests/test_redis_absent.py");
677                assert!(counter_test
678                    .as_deref()
679                    .is_some_and(|c| c.contains("flips_red")));
680                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
681                assert_eq!(verified_at_sha.len(), 40);
682            }
683            _ => panic!("expected a test check"),
684        }
685    }
686
687    #[test]
688    fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
689        // given: a store and a test binding missing --counter-test
690        let r = repo();
691
692        // when: the decision is captured
693        let e = run(
694            &r,
695            Some("d"),
696            &s(&[
697                "--assume",
698                "c",
699                "--assume-test",
700                "pytest x",
701                "--on-platform",
702                "linux-ci",
703                "--triggered-by",
704                "f",
705                "--surface",
706                "s",
707                "--verified-at-sha",
708                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
709                "--blame",
710                "Wang Yu",
711            ]),
712        );
713
714        // then: it is rejected
715        assert!(e.is_err());
716    }
717
718    #[test]
719    fn decide_should_reject_a_test_binding_when_the_counter_test_is_empty() {
720        // given: a store and a test binding whose --counter-test is empty
721        let r = repo();
722
723        // when: the decision is captured with an empty counter-test
724        let e = run(
725            &r,
726            Some("d"),
727            &s(&[
728                "--assume",
729                "c",
730                "--assume-test",
731                "pytest x",
732                "--counter-test",
733                "",
734                "--on-platform",
735                "linux-ci",
736                "--triggered-by",
737                "f",
738                "--surface",
739                "s",
740                "--verified-at-sha",
741                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
742                "--blame",
743                "Wang Yu",
744            ]),
745        );
746
747        // then: an empty counter-test is a vacuous binding — rejected at the write path too
748        assert!(e.is_err());
749    }
750
751    #[test]
752    fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
753        // given: a store and a test binding with no --verified-at-sha in a non-git dir
754        let r = repo();
755
756        // when: the decision is captured
757        let e = run(
758            &r,
759            Some("d"),
760            &s(&[
761                "--assume",
762                "c",
763                "--assume-test",
764                "pytest x",
765                "--counter-test",
766                "ct",
767                "--on-platform",
768                "linux-ci",
769                "--triggered-by",
770                "f",
771                "--surface",
772                "s",
773                "--blame",
774                "Wang Yu",
775            ]),
776        );
777
778        // then: it is rejected
779        assert!(e.is_err());
780    }
781
782    #[test]
783    fn migrate_bind_should_build_a_harvested_test_check_when_no_counter_test() {
784        // given: the migrate-only inputs — a ref, a sha, and the FULL 3-key liveness, but NO
785        // counter-test (you cannot half-harvest: liveness stays mandatory, falsifiability does not)
786        let check = harvested_test_check(
787            "pytest tests/test_invariant_no_redis.py".into(),
788            "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
789            vec!["linux-ci".into()],
790            vec!["pyproject.toml".into()],
791            vec!["pyproject-deps".into()],
792        )
793        .expect("the full liveness is present, so the harvested binding is well-formed");
794
795        // then: it is a Test check carrying counter_test None (harvested) with liveness intact
796        match check {
797            Check::Test {
798                reference,
799                counter_test,
800                liveness,
801                verified_at_sha,
802            } => {
803                assert_eq!(reference, "pytest tests/test_invariant_no_redis.py");
804                assert_eq!(counter_test, None); // harvested: falsifiability not yet proven
805                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
806                assert_eq!(liveness.triggered_by, vec!["pyproject.toml".to_string()]);
807                assert_eq!(liveness.surfaces, vec!["pyproject-deps".to_string()]);
808                assert_eq!(verified_at_sha.len(), 40);
809            }
810            _ => panic!("expected a harvested test check"),
811        }
812    }
813
814    #[test]
815    fn migrate_bind_should_reject_a_harvested_binding_when_a_liveness_key_is_missing() {
816        // given: the migrate-only inputs with an empty surfaces key (a half-harvest attempt)
817        let e = harvested_test_check(
818            "pytest x".into(),
819            "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
820            vec!["linux-ci".into()],
821            vec!["pyproject.toml".into()],
822            vec![], // no --surface: the 3-key liveness is incomplete
823        );
824
825        // then: it is rejected — harvesting drops the counter-test, never the liveness
826        assert!(e.is_err());
827    }
828
829    #[test]
830    fn decide_should_still_error_without_a_counter_test() {
831        // given: the migrate-only harvested path now exists; pin that the decide path is UNCHANGED
832        // — a `--assume-test` binding with full liveness but no --counter-test STILL errors (the
833        // strict capture.rs guard stays byte-for-byte; harvesting is migrate-only, not decide-wide).
834        let r = repo();
835
836        // when: a decision binds a test with full liveness but omits --counter-test
837        let e = run(
838            &r,
839            Some("d"),
840            &s(&[
841                "--assume",
842                "c",
843                "--assume-test",
844                "pytest x",
845                "--on-platform",
846                "linux-ci",
847                "--triggered-by",
848                "f",
849                "--surface",
850                "s",
851                "--verified-at-sha",
852                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
853                "--blame",
854                "Wang Yu",
855            ]),
856        );
857
858        // then: it errors — no vacuous binding on the decide path
859        assert!(e.is_err());
860    }
861
862    #[test]
863    fn append_should_compute_the_frozen_genesis_id_when_given_the_genesis_decision() {
864        // given: a store and the genesis decision assembled as a Decision (the SAME fields the
865        // golden vector freezes) — proving decide + migrate share one compute_id / write_tick path.
866        let r = repo();
867        let d = Decision {
868            observe: "evaluating retrieval backend".into(),
869            decision: "freeze the retrieval schema for v2".into(),
870            grounds: vec![
871                Ground {
872                    claim: "team still wants a frozen schema".into(),
873                    supports: "chosen".into(),
874                    check: Some(Check::Person {
875                        reference: "Q3 infra review".into(),
876                    }),
877                },
878                Ground {
879                    claim: "pgvector would lock our schema".into(),
880                    supports: "rejected:pgvector".into(),
881                    check: None,
882                },
883            ],
884            blame: "Wang Yu".into(),
885            authority: None,
886            jurisdiction: None,
887            round_id: None,
888        };
889
890        // when: it is appended onto the empty store (genesis: parent_id == "")
891        let t = append(&r, d).expect("ok");
892
893        // then: the content-addressed id matches the frozen genesis golden — the shared append
894        // hashes byte-identically to the legacy decide tail (no golden drift from the refactor).
895        assert_eq!(t.id, "e2b337f53a1f");
896        assert_eq!(t.parent_id, "");
897        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
898    }
899
900    #[test]
901    fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
902        // given: a store inside a git repo with a configured author, and no --blame
903        let r = repo();
904        for a in [
905            ["init"].as_slice(),
906            ["config", "user.name", "Ada Lovelace"].as_slice(),
907        ] {
908            std::process::Command::new("git")
909                .args(a)
910                .current_dir(&r)
911                .output()
912                .unwrap();
913        }
914
915        // when: a decision is captured without --blame
916        let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
917
918        // then: blame is resolved from git config user.name
919        assert_eq!(t.blame, "Ada Lovelace");
920    }
921}