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    pub jurisdiction: Option<String>, // bookkeeping (declared ∈ {A,B,C,D}, not hashed); C/D = detect-only
16    pub round_id: Option<String>,     // bookkeeping (declared join/dedup key, not hashed)
17}
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct Ground {
21    pub claim: String,
22    pub supports: String, // "chosen" | "rejected:<option>"
23    pub check: Option<Check>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum Check {
28    Person {
29        reference: String,
30    }, // by=person, ref=note
31    Test {
32        reference: String,            // by=test, ref=selector
33        verified_at_sha: String,      // 40 lowercase hex
34        counter_test: Option<String>, // None = harvested (falsifiability not yet proven)
35        liveness: Liveness,
36    },
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct Liveness {
41    pub platforms: Vec<String>,
42    pub triggered_by: Vec<String>,
43    pub surfaces: Vec<String>,
44}
45
46use crate::canonical::hashed_value;
47use serde_json::{Map, Value};
48
49/// The on-disk tick: the hashed fields + the excluded bookkeeping at top level.
50pub fn full_value(t: &Tick) -> Value {
51    let mut v = hashed_value(t);
52    if let Value::Object(map) = &mut v {
53        map.insert("id".into(), Value::String(t.id.clone()));
54        map.insert("status".into(), Value::String(t.status.clone()));
55        map.insert("held_since".into(), Value::String(t.held_since.clone()));
56        map.insert("blame".into(), Value::String(t.blame.clone()));
57        if let Some(a) = &t.authority {
58            map.insert("authority".into(), Value::String(a.clone()));
59        }
60        if let Some(j) = &t.jurisdiction {
61            map.insert("jurisdiction".into(), Value::String(j.clone()));
62        }
63        if let Some(r) = &t.round_id {
64            map.insert("round_id".into(), Value::String(r.clone()));
65        }
66    }
67    v
68}
69
70/// The closed jurisdiction vocabulary: A/B may gate; C/D are detect-only (structurally ungateable).
71pub(crate) fn validate_jurisdiction(val: &str) -> Result<(), String> {
72    if matches!(val, "A" | "B" | "C" | "D") {
73        Ok(())
74    } else {
75        Err(format!(
76            "jurisdiction must be one of A, B, C, D (got {val:?})"
77        ))
78    }
79}
80
81pub(crate) fn only_keys(
82    obj: &Map<String, Value>,
83    allowed: &[&str],
84    what: &str,
85) -> Result<(), String> {
86    for k in obj.keys() {
87        if !allowed.contains(&k.as_str()) {
88            return Err(format!("{what}: field outside closed schema: {k}"));
89        }
90    }
91    Ok(())
92}
93
94pub(crate) fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
95    obj.get(k)
96        .and_then(|x| x.as_str())
97        .map(|s| s.to_string())
98        .ok_or(format!("missing or non-string field: {k}"))
99}
100
101pub(crate) fn is_40_lower_hex(s: &str) -> bool {
102    s.len() == 40
103        && s.bytes()
104            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
105}
106
107fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
108    let a = obj
109        .get(k)
110        .and_then(|x| x.as_array())
111        .ok_or(format!("liveness.{k} missing/not array"))?;
112    let mut out = Vec::new();
113    for e in a {
114        let s = e
115            .as_str()
116            .ok_or(format!("liveness.{k} element not a string"))?;
117        if s.is_empty() {
118            return Err(format!("liveness.{k} has an empty element"));
119        }
120        out.push(s.to_string());
121    }
122    if out.is_empty() {
123        return Err(format!("liveness.{k} must be non-empty"));
124    }
125    Ok(out)
126}
127
128fn check_from_value(v: &Value) -> Result<Check, String> {
129    let obj = v.as_object().ok_or("check is not an object")?;
130    match obj.get("by").and_then(|x| x.as_str()) {
131        Some("person") => {
132            only_keys(obj, &["by", "ref"], "person check")?;
133            let reference = req_str(obj, "ref")?;
134            if reference.is_empty() {
135                return Err("person check ref is empty".into());
136            }
137            Ok(Check::Person { reference })
138        }
139        Some("test") => {
140            only_keys(
141                obj,
142                &["by", "ref", "verified_at_sha", "counter_test", "liveness"],
143                "test check",
144            )?;
145            let reference = req_str(obj, "ref")?;
146            if reference.is_empty() {
147                return Err("test check ref is empty".into());
148            }
149            let verified_at_sha = req_str(obj, "verified_at_sha")?;
150            if !is_40_lower_hex(&verified_at_sha) {
151                return Err(format!(
152                    "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
153                ));
154            }
155            // counter_test is optional: absent = a harvested binding. When present it MUST be a
156            // non-empty string (req_str accepts "", so guard non-emptiness explicitly here).
157            let counter_test = match obj.get("counter_test") {
158                None => None,
159                Some(cv) => {
160                    let s = cv.as_str().ok_or("counter_test present but not a string")?;
161                    if s.is_empty() {
162                        return Err("counter_test present but empty".into());
163                    }
164                    Some(s.to_string())
165                }
166            };
167            let lv = obj
168                .get("liveness")
169                .and_then(|x| x.as_object())
170                .ok_or("liveness missing/not object")?;
171            only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
172            let liveness = Liveness {
173                platforms: nonempty_str_set(lv, "platforms")?,
174                triggered_by: nonempty_str_set(lv, "triggered_by")?,
175                surfaces: nonempty_str_set(lv, "surfaces")?,
176            };
177            Ok(Check::Test {
178                reference,
179                verified_at_sha,
180                counter_test,
181                liveness,
182            })
183        }
184        other => Err(format!(
185            "check.by must be \"test\" or \"person\", got {other:?}"
186        )),
187    }
188}
189
190fn ground_from_value(v: &Value) -> Result<Ground, String> {
191    let obj = v.as_object().ok_or("ground is not an object")?;
192    only_keys(obj, &["claim", "supports", "check"], "ground")?;
193    let claim = req_str(obj, "claim")?;
194    if claim.is_empty() {
195        return Err("ground claim is empty".into());
196    }
197    let supports = req_str(obj, "supports")?;
198    let ok_supports = supports == "chosen"
199        || (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
200    if !ok_supports {
201        return Err(format!("invalid supports: {supports}"));
202    }
203    let check = match obj.get("check") {
204        None => None,
205        Some(cv) => Some(check_from_value(cv)?),
206    };
207    Ok(Ground {
208        claim,
209        supports,
210        check,
211    })
212}
213
214/// The hashed/identity set: top-level keys that the schema is STRICT about. A tick whose payload
215/// has an unknown key INSIDE these (e.g. a stray key on a ground/check) is rejected by the nested
216/// strict `only_keys`; these names are also what `unknown_top_level_keys` excludes when surfacing
217/// a tolerated forward-compat key as a warning.
218pub(crate) const HASHED_TOP_LEVEL_KEYS: &[&str] = &[
219    "id",
220    "parent_id",
221    "observe",
222    "decision",
223    "grounds",
224    "status",
225    "held_since",
226    "blame",
227];
228
229/// The known-non-hashed allow-list: declared bookkeeping fields, validated but not hashed.
230pub(crate) const KNOWN_NON_HASHED_KEYS: &[&str] = &["authority", "jurisdiction", "round_id"];
231
232/// A tick's top-level keys that are neither hashed/identity nor a known-non-hashed field — the
233/// truly-unknown, tolerated forward-compat keys (`from_value` parses them through; verify warns).
234pub(crate) fn unknown_top_level_keys(obj: &Map<String, Value>) -> Vec<String> {
235    obj.keys()
236        .filter(|k| {
237            !HASHED_TOP_LEVEL_KEYS.contains(&k.as_str())
238                && !KNOWN_NON_HASHED_KEYS.contains(&k.as_str())
239        })
240        .cloned()
241        .collect()
242}
243
244/// Strict parse of an on-disk tick — this IS the R1 (closed schema) + R2 (check shape) check.
245///
246/// Two-tier forward-compat (T3): the hashed/identity set (`HASHED_TOP_LEVEL_KEYS`) stays STRICT —
247/// a missing one is an Err, and the nested grounds/check schemas reject any unknown key inside the
248/// HASHED payload, so the content-addressed id can never carry an unvalidated field. The known
249/// non-hashed fields are validated. A truly-unknown OTHER top-level key is TOLERATED (parsed
250/// through, not rejected) so a newer writer's bookkeeping field does not brick an older reader;
251/// `ev verify` surfaces it as a `warning:` so a typo'd field name stays visible.
252pub fn from_value(v: &Value) -> Result<Tick, String> {
253    let obj = v.as_object().ok_or("tick is not an object")?;
254    let grounds_v = obj
255        .get("grounds")
256        .and_then(|x| x.as_array())
257        .ok_or("grounds missing/not array")?;
258    let mut grounds = Vec::new();
259    for gv in grounds_v {
260        grounds.push(ground_from_value(gv)?);
261    }
262    Ok(Tick {
263        id: req_str(obj, "id")?,
264        parent_id: req_str(obj, "parent_id")?,
265        observe: req_str(obj, "observe")?,
266        decision: req_str(obj, "decision")?,
267        grounds,
268        status: req_str(obj, "status")?,
269        held_since: req_str(obj, "held_since")?,
270        blame: req_str(obj, "blame")?,
271        authority: obj
272            .get("authority")
273            .and_then(|x| x.as_str())
274            .map(String::from),
275        jurisdiction: match obj.get("jurisdiction").and_then(|x| x.as_str()) {
276            None => None,
277            Some(j) => {
278                validate_jurisdiction(j)?; // out-of-vocab → Err
279                Some(j.to_string())
280            }
281        },
282        round_id: match obj.get("round_id") {
283            None => None,
284            Some(rv) => {
285                // non-empty-if-present; no other format constraint (a free-form join/dedup key).
286                let s = rv.as_str().ok_or("round_id present but not a string")?;
287                if s.is_empty() {
288                    return Err("round_id present but empty".into());
289                }
290                Some(s.to_string())
291            }
292        },
293    })
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use serde_json::json;
300
301    fn genesis_full() -> serde_json::Value {
302        json!({
303            "id": "e2b337f53a1f", "parent_id": "",
304            "observe": "o", "decision": "d",
305            "grounds": [{ "claim": "c", "supports": "chosen",
306                          "check": { "by": "person", "ref": "Q3 review" } }],
307            "status": "live", "held_since": "", "blame": "Wang Yu"
308        })
309    }
310
311    #[test]
312    fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
313        // given: a well-formed on-disk tick value
314        let v = genesis_full();
315
316        // when: it is parsed through from_value
317        let t = from_value(&v).expect("valid");
318
319        // then: the parsed fields and the person check are preserved
320        assert_eq!(t.decision, "d");
321        assert_eq!(t.grounds.len(), 1);
322        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
323    }
324
325    #[test]
326    fn from_value_should_round_trip_an_authority_tag_when_present() {
327        // given: a well-formed tick carrying an authority tag
328        let mut v = genesis_full();
329        v.as_object_mut()
330            .unwrap()
331            .insert("authority".into(), json!("user-ruled"));
332
333        // when: it is parsed
334        let t = from_value(&v).expect("valid");
335
336        // then: the authority tag is preserved
337        assert_eq!(t.authority.as_deref(), Some("user-ruled"));
338    }
339
340    #[test]
341    fn from_value_should_default_authority_to_none_when_absent() {
342        // given: a tick with no authority field (the existing genesis shape)
343        let v = genesis_full();
344
345        // when: it is parsed
346        let t = from_value(&v).expect("valid");
347
348        // then: authority is None (absent = no claim)
349        assert_eq!(t.authority, None);
350    }
351
352    #[test]
353    fn from_value_should_reject_the_tick_when_a_hashed_identity_field_is_missing() {
354        // given: a tick value missing a hashed/identity field (the strict tier stays closed)
355        let mut v = genesis_full();
356        v.as_object_mut().unwrap().remove("decision");
357
358        // when: it is parsed through from_value
359        let result = from_value(&v);
360
361        // then: parsing fails — the hashed/identity set is not forward-compat-tolerant
362        assert!(result.is_err());
363    }
364
365    #[test]
366    fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
367        // given: a tick whose person check also carries a test-only liveness field
368        let mut v = genesis_full();
369        v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
370
371        // when: it is parsed through from_value
372        let result = from_value(&v);
373
374        // then: parsing fails
375        assert!(result.is_err());
376    }
377
378    #[test]
379    fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
380        // given: a tick with a test check whose verified_at_sha is not 40 lowercase hex
381        let mut v = genesis_full();
382        v["grounds"][0]["check"] = json!({
383            "by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
384            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
385        });
386
387        // when: it is parsed through from_value
388        let result = from_value(&v);
389
390        // then: parsing fails
391        assert!(result.is_err());
392    }
393
394    #[test]
395    fn from_value_should_reject_an_empty_counter_test_when_present() {
396        // given: a tick with a test check whose counter_test is present but empty
397        let mut v = genesis_full();
398        v["grounds"][0]["check"] = json!({
399            "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "",
400            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
401        });
402
403        // when: it is parsed through from_value
404        let result = from_value(&v);
405
406        // then: parsing fails (non-empty-if-present; req_str would have accepted "")
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn from_value_should_round_trip_a_harvested_test_check_when_counter_test_is_absent() {
412        // given: a tick with a test check that omits counter_test (a harvested binding)
413        let mut v = genesis_full();
414        v["grounds"][0]["check"] = json!({
415            "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
416            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
417        });
418
419        // when: it is parsed through from_value
420        let t = from_value(&v).expect("valid");
421
422        // then: the test check parses with counter_test None
423        assert!(matches!(
424            &t.grounds[0].check,
425            Some(Check::Test {
426                counter_test: None,
427                ..
428            })
429        ));
430    }
431
432    #[test]
433    fn from_value_should_round_trip_a_jurisdiction_tag_when_present() {
434        // given: a well-formed tick carrying a jurisdiction tag in the vocabulary
435        let mut v = genesis_full();
436        v.as_object_mut()
437            .unwrap()
438            .insert("jurisdiction".into(), json!("C"));
439
440        // when: it is parsed
441        let t = from_value(&v).expect("valid");
442
443        // then: the jurisdiction tag is preserved
444        assert_eq!(t.jurisdiction.as_deref(), Some("C"));
445    }
446
447    #[test]
448    fn from_value_should_default_jurisdiction_to_none_when_absent() {
449        // given: a tick with no jurisdiction field (the existing genesis shape)
450        let v = genesis_full();
451
452        // when: it is parsed
453        let t = from_value(&v).expect("valid");
454
455        // then: jurisdiction is None (absent = no claim)
456        assert_eq!(t.jurisdiction, None);
457    }
458
459    #[test]
460    fn from_value_should_round_trip_round_id_when_present() {
461        // given: a well-formed tick carrying a round_id join/dedup key
462        let mut v = genesis_full();
463        v.as_object_mut()
464            .unwrap()
465            .insert("round_id".into(), json!("R2289"));
466
467        // when: it is parsed
468        let t = from_value(&v).expect("valid");
469
470        // then: the round_id is preserved (durable, non-hashed)
471        assert_eq!(t.round_id.as_deref(), Some("R2289"));
472    }
473
474    #[test]
475    fn from_value_should_default_round_id_to_none_when_absent() {
476        // given: a tick with no round_id field (the existing genesis shape)
477        let v = genesis_full();
478
479        // when: it is parsed
480        let t = from_value(&v).expect("valid");
481
482        // then: round_id is None (absent = no claim)
483        assert_eq!(t.round_id, None);
484    }
485
486    #[test]
487    fn from_value_should_reject_an_empty_round_id_when_present() {
488        // given: a tick whose round_id is present but empty
489        let mut v = genesis_full();
490        v.as_object_mut()
491            .unwrap()
492            .insert("round_id".into(), json!(""));
493
494        // when: it is parsed
495        let result = from_value(&v);
496
497        // then: parsing fails (non-empty-if-present; no other format constraint)
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn from_value_should_reject_an_out_of_vocab_jurisdiction() {
503        // given: a tick whose jurisdiction is outside the closed {A,B,C,D} vocabulary
504        let mut v = genesis_full();
505        v.as_object_mut()
506            .unwrap()
507            .insert("jurisdiction".into(), json!("Z"));
508
509        // when: it is parsed
510        let result = from_value(&v);
511
512        // then: parsing fails (vocab-validated, like authority)
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
518        // given: a tick with a test check whose ref is empty
519        let mut v = genesis_full();
520        v["grounds"][0]["check"] = json!({
521            "by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
522            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
523        });
524
525        // when: it is parsed through from_value
526        let result = from_value(&v);
527
528        // then: parsing fails
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn from_value_should_tolerate_an_unknown_non_hashed_key_when_reading() {
534        // given: a well-formed tick carrying a bogus extra top-level key (a forward-compat field)
535        let mut v = genesis_full();
536        v.as_object_mut()
537            .unwrap()
538            .insert("future_field".into(), json!("x"));
539
540        // when: it is parsed through from_value
541        let t = from_value(&v).expect("an unknown top-level key is tolerated (parsed-through)");
542
543        // then: the known fields are intact (the unknown key is ignored, not rejected)
544        assert_eq!(t.decision, "d");
545        assert_eq!(t.observe, "o");
546        assert_eq!(t.grounds.len(), 1);
547    }
548
549    #[test]
550    fn from_value_should_still_reject_an_unknown_key_inside_the_hashed_payload() {
551        // given: a well-formed tick whose ground (part of the hashed payload) carries an unknown key
552        let mut v = genesis_full();
553        v["grounds"][0]
554            .as_object_mut()
555            .unwrap()
556            .insert("future_field".into(), json!("x"));
557
558        // when: it is parsed through from_value
559        let result = from_value(&v);
560
561        // then: parsing fails — the hashed payload stays a strictly closed schema
562        assert!(result.is_err());
563    }
564}