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