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