Skip to main content

ev/
tick.rs

1//! The decision tick and its parts. No serde derives: canonical and on-disk
2//! encodings are built by hand (tick.rs / canonical.rs) for exact byte control.
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct Tick {
6    pub id: String,           // bookkeeping (the hash output)
7    pub parent_id: String,    // hashed; "" on genesis, present
8    pub observe: String,      // hashed
9    pub decision: String,     // hashed
10    pub grounds: Vec<Ground>, // hashed
11    pub status: String,       // bookkeeping
12    pub held_since: String,   // bookkeeping
13    pub blame: String,        // bookkeeping
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct Ground {
18    pub claim: String,
19    pub supports: String, // "chosen" | "rejected:<option>"
20    pub check: Option<Check>,
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub enum Check {
25    Person {
26        reference: String,
27    }, // by=person, ref=note
28    Test {
29        reference: String,       // by=test, ref=selector
30        verified_at_sha: String, // 40 lowercase hex
31        counter_test: String,
32        liveness: Liveness,
33    },
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct Liveness {
38    pub platforms: Vec<String>,
39    pub triggered_by: Vec<String>,
40    pub surfaces: Vec<String>,
41}
42
43use crate::canonical::hashed_value;
44use serde_json::{Map, Value};
45
46/// The on-disk tick: the hashed fields + the excluded bookkeeping at top level.
47pub fn full_value(t: &Tick) -> Value {
48    let mut v = hashed_value(t);
49    if let Value::Object(map) = &mut v {
50        map.insert("id".into(), Value::String(t.id.clone()));
51        map.insert("status".into(), Value::String(t.status.clone()));
52        map.insert("held_since".into(), Value::String(t.held_since.clone()));
53        map.insert("blame".into(), Value::String(t.blame.clone()));
54    }
55    v
56}
57
58fn only_keys(obj: &Map<String, Value>, allowed: &[&str], what: &str) -> Result<(), String> {
59    for k in obj.keys() {
60        if !allowed.contains(&k.as_str()) {
61            return Err(format!("{what}: field outside closed schema: {k}"));
62        }
63    }
64    Ok(())
65}
66
67fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
68    obj.get(k)
69        .and_then(|x| x.as_str())
70        .map(|s| s.to_string())
71        .ok_or(format!("missing or non-string field: {k}"))
72}
73
74fn is_40_lower_hex(s: &str) -> bool {
75    s.len() == 40
76        && s.bytes()
77            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
78}
79
80fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
81    let a = obj
82        .get(k)
83        .and_then(|x| x.as_array())
84        .ok_or(format!("liveness.{k} missing/not array"))?;
85    let mut out = Vec::new();
86    for e in a {
87        let s = e
88            .as_str()
89            .ok_or(format!("liveness.{k} element not a string"))?;
90        if s.is_empty() {
91            return Err(format!("liveness.{k} has an empty element"));
92        }
93        out.push(s.to_string());
94    }
95    if out.is_empty() {
96        return Err(format!("liveness.{k} must be non-empty"));
97    }
98    Ok(out)
99}
100
101fn check_from_value(v: &Value) -> Result<Check, String> {
102    let obj = v.as_object().ok_or("check is not an object")?;
103    match obj.get("by").and_then(|x| x.as_str()) {
104        Some("person") => {
105            only_keys(obj, &["by", "ref"], "person check")?;
106            let reference = req_str(obj, "ref")?;
107            if reference.is_empty() {
108                return Err("person check ref is empty".into());
109            }
110            Ok(Check::Person { reference })
111        }
112        Some("test") => {
113            only_keys(
114                obj,
115                &["by", "ref", "verified_at_sha", "counter_test", "liveness"],
116                "test check",
117            )?;
118            let reference = req_str(obj, "ref")?;
119            if reference.is_empty() {
120                return Err("test check ref is empty".into());
121            }
122            let verified_at_sha = req_str(obj, "verified_at_sha")?;
123            if !is_40_lower_hex(&verified_at_sha) {
124                return Err(format!(
125                    "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
126                ));
127            }
128            let counter_test = req_str(obj, "counter_test")?;
129            let lv = obj
130                .get("liveness")
131                .and_then(|x| x.as_object())
132                .ok_or("liveness missing/not object")?;
133            only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
134            let liveness = Liveness {
135                platforms: nonempty_str_set(lv, "platforms")?,
136                triggered_by: nonempty_str_set(lv, "triggered_by")?,
137                surfaces: nonempty_str_set(lv, "surfaces")?,
138            };
139            Ok(Check::Test {
140                reference,
141                verified_at_sha,
142                counter_test,
143                liveness,
144            })
145        }
146        other => Err(format!(
147            "check.by must be \"test\" or \"person\", got {other:?}"
148        )),
149    }
150}
151
152fn ground_from_value(v: &Value) -> Result<Ground, String> {
153    let obj = v.as_object().ok_or("ground is not an object")?;
154    only_keys(obj, &["claim", "supports", "check"], "ground")?;
155    let claim = req_str(obj, "claim")?;
156    if claim.is_empty() {
157        return Err("ground claim is empty".into());
158    }
159    let supports = req_str(obj, "supports")?;
160    let ok_supports = supports == "chosen"
161        || (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
162    if !ok_supports {
163        return Err(format!("invalid supports: {supports}"));
164    }
165    let check = match obj.get("check") {
166        None => None,
167        Some(cv) => Some(check_from_value(cv)?),
168    };
169    Ok(Ground {
170        claim,
171        supports,
172        check,
173    })
174}
175
176/// Strict parse of an on-disk tick — this IS the R1 (closed schema) + R2 (check shape) check.
177pub fn from_value(v: &Value) -> Result<Tick, String> {
178    let obj = v.as_object().ok_or("tick is not an object")?;
179    only_keys(
180        obj,
181        &[
182            "id",
183            "parent_id",
184            "observe",
185            "decision",
186            "grounds",
187            "status",
188            "held_since",
189            "blame",
190        ],
191        "tick",
192    )?;
193    let grounds_v = obj
194        .get("grounds")
195        .and_then(|x| x.as_array())
196        .ok_or("grounds missing/not array")?;
197    let mut grounds = Vec::new();
198    for gv in grounds_v {
199        grounds.push(ground_from_value(gv)?);
200    }
201    Ok(Tick {
202        id: req_str(obj, "id")?,
203        parent_id: req_str(obj, "parent_id")?,
204        observe: req_str(obj, "observe")?,
205        decision: req_str(obj, "decision")?,
206        grounds,
207        status: req_str(obj, "status")?,
208        held_since: req_str(obj, "held_since")?,
209        blame: req_str(obj, "blame")?,
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    fn genesis_full() -> serde_json::Value {
219        json!({
220            "id": "e2b337f53a1f", "parent_id": "",
221            "observe": "o", "decision": "d",
222            "grounds": [{ "claim": "c", "supports": "chosen",
223                          "check": { "by": "person", "ref": "Q3 review" } }],
224            "status": "live", "held_since": "", "blame": "Wang Yu"
225        })
226    }
227
228    #[test]
229    fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
230        // given: a well-formed on-disk tick value
231        let v = genesis_full();
232
233        // when: it is parsed through from_value
234        let t = from_value(&v).expect("valid");
235
236        // then: the parsed fields and the person check are preserved
237        assert_eq!(t.decision, "d");
238        assert_eq!(t.grounds.len(), 1);
239        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
240    }
241
242    #[test]
243    fn from_value_should_reject_the_tick_when_it_has_an_unknown_top_level_field() {
244        // given: a tick value carrying a field outside the closed schema
245        let mut v = genesis_full();
246        v.as_object_mut()
247            .unwrap()
248            .insert("health".into(), json!("0.8"));
249
250        // when: it is parsed through from_value
251        let result = from_value(&v);
252
253        // then: parsing fails
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
259        // given: a tick whose person check also carries a test-only liveness field
260        let mut v = genesis_full();
261        v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
262
263        // when: it is parsed through from_value
264        let result = from_value(&v);
265
266        // then: parsing fails
267        assert!(result.is_err());
268    }
269
270    #[test]
271    fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
272        // given: a tick with a test check whose verified_at_sha is not 40 lowercase hex
273        let mut v = genesis_full();
274        v["grounds"][0]["check"] = json!({
275            "by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
276            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
277        });
278
279        // when: it is parsed through from_value
280        let result = from_value(&v);
281
282        // then: parsing fails
283        assert!(result.is_err());
284    }
285
286    #[test]
287    fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
288        // given: a tick with a test check whose ref is empty
289        let mut v = genesis_full();
290        v["grounds"][0]["check"] = json!({
291            "by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
292            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
293        });
294
295        // when: it is parsed through from_value
296        let result = from_value(&v);
297
298        // then: parsing fails
299        assert!(result.is_err());
300    }
301}