Skip to main content

ev/
guard.rs

1//! `ev guard "<selector>" <id> [<ground>]` — attach an existing test to a ground as a
2//! data check (after the fact). Because `check` is hashed, this writes a NEW CHILD.
3use crate::canonical::compute_id;
4use crate::store::Store;
5use crate::tick::{Check, Ground, Liveness, Tick};
6use std::path::Path;
7
8pub struct GuardArgs {
9    pub selector: String,
10    pub id: String,
11    pub target: Option<String>, // ground claim or index; required if >1 unbound ground
12    pub counter_test: String,
13    pub platforms: Vec<String>,
14    pub triggered_by: Vec<String>,
15    pub surfaces: Vec<String>,
16    pub verified_at_sha: Option<String>,
17    pub blame: Option<String>,
18    pub authority: Option<String>,
19}
20
21fn resolve_target(grounds: &[Ground], target: &Option<String>) -> Result<usize, String> {
22    let unbound: Vec<usize> = grounds
23        .iter()
24        .enumerate()
25        .filter(|(_, g)| g.check.is_none())
26        .map(|(i, _)| i)
27        .collect();
28    match target {
29        None => match unbound.as_slice() {
30            [one] => Ok(*one),
31            [] => Err("no unbound ground to guard".into()),
32            _ => Err("more than one unbound ground — name the target (claim or index)".into()),
33        },
34        Some(t) => {
35            if let Ok(idx) = t.parse::<usize>() {
36                if idx < grounds.len() {
37                    return Ok(idx);
38                }
39                return Err(format!("ground index {idx} out of range"));
40            }
41            let matches: Vec<usize> = grounds
42                .iter()
43                .enumerate()
44                .filter(|(_, g)| g.claim == *t)
45                .map(|(i, _)| i)
46                .collect();
47            match matches.as_slice() {
48                [one] => Ok(*one),
49                [] => Err(format!("no ground with claim {t:?}")),
50                _ => Err(format!("ambiguous: multiple grounds with claim {t:?}")),
51            }
52        }
53    }
54}
55
56pub fn run(repo: &Path, a: GuardArgs) -> Result<Tick, String> {
57    let store = Store::at(repo);
58    let parent = store
59        .read_tick(&a.id)
60        .map_err(|e| format!("{e}"))?
61        .ok_or(format!("no tick with id {}", a.id))?;
62    let head = store
63        .read_head()
64        .map_err(|e| format!("reading HEAD: {e}"))?;
65    if a.id != head {
66        return Err(format!(
67            "guard can only amend the current HEAD decision; {} is not HEAD ({})",
68            a.id, head
69        ));
70    }
71    let idx = resolve_target(&parent.grounds, &a.target)?;
72    let g = &parent.grounds[idx];
73    // R2: a human-rechecked (person) ground can never be force-bound to a test.
74    if let Some(Check::Person { .. }) = g.check {
75        return Err("a human-rechecked ground cannot carry a test (R2 hard error)".into());
76    }
77    // Validate authority FIRST so the rejected-road tripwire gate below reads a vetted value (and an
78    // out-of-vocab authority fails loudly rather than silently failing the user-ruled comparison).
79    if let Some(val) = &a.authority {
80        crate::capture::validate_authority(val)?;
81    }
82    // 0.1.8: a rejected road (closed by a ruling) may be guarded with a falsifiable tripwire, but
83    // ONLY when the binding declares --authority user-ruled (the human's deliberate closed-road call).
84    // Mirrors capture.rs build_ground; the counter-test stays required below (no harvested tripwire).
85    // provenance is hard-stamped human-now on the child (line below), so a guard can never create an
86    // agent-proposed gating tripwire.
87    if g.supports.starts_with("rejected:") && a.authority.as_deref() != Some("user-ruled") {
88        return Err(
89            "a rejected road can carry a tripwire test only when guarded with --authority user-ruled"
90                .into(),
91        );
92    }
93    if g.check.is_some() {
94        return Err("ground already has a check".into());
95    }
96    if a.counter_test.trim().is_empty() {
97        return Err("a test binding requires a counter-test (no vacuous binding)".into());
98    }
99    if a.platforms.is_empty() || a.triggered_by.is_empty() || a.surfaces.is_empty() {
100        return Err(
101            "a test binding requires at least one platform, triggered-by, and surface".into(),
102        );
103    }
104    let verified_at_sha = crate::capture::resolve_sha(repo, &a.verified_at_sha)?;
105    let blame = crate::capture::resolve_blame(repo, a.blame)?;
106
107    let mut grounds = parent.grounds.clone();
108    grounds[idx] = Ground {
109        claim: grounds[idx].claim.clone(),
110        supports: grounds[idx].supports.clone(),
111        check: Some(Check::Test {
112            reference: a.selector,
113            verified_at_sha,
114            counter_test: Some(a.counter_test),
115            liveness: Liveness {
116                platforms: a.platforms,
117                triggered_by: a.triggered_by,
118                surfaces: a.surfaces,
119            },
120        }),
121    };
122    let held_since = time::OffsetDateTime::now_utc()
123        .format(&time::format_description::well_known::Rfc3339)
124        .map_err(|e| format!("timestamp: {e}"))?;
125    let mut child = Tick {
126        id: String::new(),
127        parent_id: parent.id.clone(),
128        observe: parent.observe.clone(),
129        decision: parent.decision.clone(),
130        grounds,
131        status: "live".into(),
132        held_since,
133        blame,
134        authority: a.authority,
135        jurisdiction: parent.jurisdiction.clone(), // a sibling tag of the decision; inherited by the child
136        source_ref: parent.source_ref.clone(), // the opaque source identity of the decision; inherited by the child
137        // NOT inherited: provenance is a property of the authorship ACT, not the decision. A guard is a
138        // fresh human act, hard-stamped human-now (the absent default) — the launder defense.
139        provenance: None,
140        corrects: None,
141    };
142    child.id = compute_id(&child);
143    store
144        .write_tick(&child)
145        .map_err(|e| format!("writing tick: {e}"))?;
146    Ok(child)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn repo_with_unbound() -> (std::path::PathBuf, String) {
154        use std::sync::atomic::{AtomicU64, Ordering};
155        static N: AtomicU64 = AtomicU64::new(0);
156        let p = std::env::temp_dir().join(format!(
157            "ev-guard-{}-{}",
158            std::process::id(),
159            N.fetch_add(1, Ordering::Relaxed)
160        ));
161        let _ = std::fs::remove_dir_all(&p);
162        std::fs::create_dir_all(&p).unwrap();
163        Store::at(&p).init().unwrap();
164        let args: Vec<String> = [
165            "--assume",
166            "schema stays frozen",
167            "--assume",
168            "team ok",
169            "--revisit",
170            "Q3",
171            "--blame",
172            "Wang Yu",
173        ]
174        .iter()
175        .map(|x| x.to_string())
176        .collect();
177        let t = crate::capture::run(&p, Some("build our own retrieval"), &args).unwrap();
178        (p, t.id)
179    }
180    fn args(selector: &str, id: &str, target: Option<&str>) -> GuardArgs {
181        GuardArgs {
182            selector: selector.into(),
183            id: id.into(),
184            target: target.map(|s| s.into()),
185            counter_test: "pytest x::counter".into(),
186            platforms: vec!["linux-ci".into()],
187            triggered_by: vec!["f".into()],
188            surfaces: vec!["s".into()],
189            verified_at_sha: Some("d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into()),
190            blame: Some("Wang Yu".into()),
191            authority: None,
192        }
193    }
194
195    #[test]
196    fn guard_should_bind_a_named_unbound_ground_and_write_a_child_when_the_target_is_named() {
197        // given: a HEAD tick with an unbound "schema stays frozen" ground
198        let (p, id) = repo_with_unbound();
199
200        // when: that named ground is guarded
201        let child = run(
202            &p,
203            args(
204                "pytest tests/test_schema_frozen.py",
205                &id,
206                Some("schema stays frozen"),
207            ),
208        )
209        .expect("ok");
210
211        // then: a child is written and the named ground now carries a test check
212        assert_eq!(child.parent_id, id);
213        let i = child
214            .grounds
215            .iter()
216            .position(|g| g.claim == "schema stays frozen")
217            .unwrap();
218        assert!(matches!(child.grounds[i].check, Some(Check::Test { .. })));
219    }
220
221    #[test]
222    fn guard_should_still_error_without_a_counter_test() {
223        // given: the migrate-only harvested path now exists; pin that the guard path is UNCHANGED
224        // — guard.rs:83-85 stays byte-for-byte strict, so an empty counter-test STILL errors here
225        // (harvesting drops the counter-test ONLY on the migrate path, never on `ev guard`).
226        let (p, id) = repo_with_unbound();
227        let mut a = args("pytest x", &id, Some("schema stays frozen"));
228        a.counter_test = "   ".into(); // an empty/whitespace counter-test is a vacuous binding
229
230        // when: that ground is guarded with no real counter-test
231        let e = run(&p, a);
232
233        // then: it errors — no vacuous binding on the guard path
234        assert!(e.is_err());
235    }
236
237    #[test]
238    fn guard_should_refuse_the_target_when_the_ground_is_human_rechecked() {
239        // given: a HEAD tick whose "team ok" ground is a human-rechecked (person) check
240        let (p, id) = repo_with_unbound();
241
242        // when: that person ground is guarded with a test
243        let e = run(&p, args("pytest x", &id, Some("team ok")));
244
245        // then: it is refused
246        assert!(e.is_err());
247    }
248
249    #[test]
250    fn guard_should_require_a_target_when_more_than_one_ground_is_unbound() {
251        // given: a HEAD tick with two unbound grounds and no target named
252        let (p, _id) = repo_with_unbound();
253        let t2 = crate::capture::run(
254            &p,
255            Some("d2"),
256            &["--assume", "a", "--assume", "b", "--blame", "Wang Yu"]
257                .iter()
258                .map(|x| x.to_string())
259                .collect::<Vec<_>>(),
260        )
261        .unwrap();
262
263        // when: the guard is run without naming a target
264        let e = run(&p, args("pytest x", &t2.id, None));
265
266        // then: it is refused
267        assert!(e.is_err());
268    }
269
270    #[test]
271    fn guard_should_refuse_the_target_when_it_is_not_head() {
272        // given: two decisions in a chain, so the first is no longer HEAD
273        let p = repo_with_unbound().0;
274        let t1 = crate::capture::run(
275            &p,
276            Some("d1"),
277            &["--assume", "a", "--blame", "Wang Yu"]
278                .iter()
279                .map(|x| x.to_string())
280                .collect::<Vec<_>>(),
281        )
282        .unwrap();
283        let _t2 = crate::capture::run(
284            &p,
285            Some("d2"),
286            &["--assume", "b", "--blame", "Wang Yu"]
287                .iter()
288                .map(|x| x.to_string())
289                .collect::<Vec<_>>(),
290        )
291        .unwrap();
292
293        // when: the non-HEAD first tick is guarded
294        let e = run(&p, args("pytest x", &t1.id, Some("a")));
295
296        // then: it is refused
297        assert!(e.is_err());
298    }
299
300    #[test]
301    fn guard_should_refuse_a_rejected_road_tripwire_when_authority_is_absent() {
302        // given: a HEAD tick whose only ground is a rejected road
303        let p = repo_with_unbound().0;
304        let t = crate::capture::run(
305            &p,
306            Some("d"),
307            &["--reject", "x: y", "--blame", "Wang Yu"]
308                .iter()
309                .map(|x| x.to_string())
310                .collect::<Vec<_>>(),
311        )
312        .unwrap();
313
314        // when: that rejected ground is guarded with a test but NO --authority user-ruled
315        let e = run(&p, args("pytest x", &t.id, Some("y")));
316
317        // then: it is refused — a rejected-road tripwire needs --authority user-ruled
318        assert!(e.is_err());
319    }
320
321    #[test]
322    fn guard_should_bind_a_tripwire_to_a_rejected_road_when_authority_is_user_ruled() {
323        // given: a HEAD tick whose only ground is a rejected road (closed by a human ruling)
324        let p = repo_with_unbound().0;
325        let t = crate::capture::run(
326            &p,
327            Some("d"),
328            &["--reject", "x: y", "--blame", "Wang Yu"]
329                .iter()
330                .map(|x| x.to_string())
331                .collect::<Vec<_>>(),
332        )
333        .unwrap();
334
335        // when: that rejected ground is guarded with a falsifiable tripwire AND --authority user-ruled
336        let mut a = args("pytest x", &t.id, Some("y"));
337        a.authority = Some("user-ruled".into());
338        let child = run(&p, a).expect("a user-ruled rejected-road tripwire binds");
339
340        // then: a child is written and the closed road now carries a test tripwire
341        assert_eq!(child.parent_id, t.id);
342        let g = child
343            .grounds
344            .iter()
345            .find(|g| g.supports.starts_with("rejected:"))
346            .expect("a rejected road");
347        assert!(matches!(g.check, Some(Check::Test { .. })));
348        // and the child is a fresh human act (provenance human-now), so it can gate
349        assert_eq!(child.provenance, None);
350    }
351}