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 source_ref: Option<Value>, // bookkeeping (opaque producer-supplied source identity — a string or object, not hashed); ev derives a dedup key, never interprets it
17    pub provenance: Option<String>, // bookkeeping (declared ∈ {imported,agent-proposed,human-now}, not hashed); absent = human-now
18    pub corrects: Option<String>, // bookkeeping (non-hashed): the explicit relation-overlay edge — this tick CORRECTS the tick with this id (written by `ev correct`). The first, and currently the ONLY, overlay relation; the general case-law graph is deliberately not built.
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct Ground {
23    pub claim: String,
24    pub supports: String, // "chosen" | "rejected:<option>"
25    pub check: Option<Check>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum Check {
30    Person {
31        reference: String,
32    }, // by=person, ref=note
33    Test {
34        reference: String,            // by=test, ref=selector
35        verified_at_sha: String,      // 40 lowercase hex
36        counter_test: Option<String>, // None = harvested (falsifiability not yet proven)
37        liveness: Liveness,
38    },
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct Liveness {
43    pub platforms: Vec<String>,
44    pub triggered_by: Vec<String>,
45    pub surfaces: Vec<String>,
46}
47
48use crate::canonical::hashed_value;
49use serde_json::{Map, Value};
50
51/// The on-disk tick: the hashed fields + the excluded bookkeeping at top level.
52pub fn full_value(t: &Tick) -> Value {
53    let mut v = hashed_value(t);
54    if let Value::Object(map) = &mut v {
55        map.insert("id".into(), Value::String(t.id.clone()));
56        map.insert("status".into(), Value::String(t.status.clone()));
57        map.insert("held_since".into(), Value::String(t.held_since.clone()));
58        map.insert("blame".into(), Value::String(t.blame.clone()));
59        if let Some(a) = &t.authority {
60            map.insert("authority".into(), Value::String(a.clone()));
61        }
62        if let Some(j) = &t.jurisdiction {
63            map.insert("jurisdiction".into(), Value::String(j.clone()));
64        }
65        if let Some(r) = &t.source_ref {
66            map.insert("source_ref".into(), r.clone());
67        }
68        if let Some(p) = &t.provenance {
69            map.insert("provenance".into(), Value::String(p.clone()));
70        }
71        if let Some(c) = &t.corrects {
72            map.insert("corrects".into(), Value::String(c.clone()));
73        }
74    }
75    v
76}
77
78/// The closed jurisdiction vocabulary: A/B may gate; C/D are detect-only (structurally ungateable).
79pub(crate) fn validate_jurisdiction(val: &str) -> Result<(), String> {
80    if matches!(val, "A" | "B" | "C" | "D") {
81        Ok(())
82    } else {
83        Err(format!(
84            "jurisdiction must be one of A, B, C, D (got {val:?})"
85        ))
86    }
87}
88
89/// The closed provenance vocabulary — how a tick entered the ledger. `human-now` (the absent
90/// default) and `agent-proposed` are fresh authorship; `imported` is faithfully-transcribed history.
91/// `verify` partitions only the R5 lexical op-lint by this tag (a transcribed historical op-word is
92/// a warning, not a fresh op-claim); every hard refusal stays hard for all provenance. The vocabulary
93/// is a non-hashed bookkeeping value — a future value is a non-breaking additive change.
94pub(crate) fn validate_provenance(val: &str) -> Result<(), String> {
95    if matches!(val, "imported" | "agent-proposed" | "human-now") {
96        Ok(())
97    } else {
98        Err(format!(
99            "provenance must be one of imported, agent-proposed, human-now (got {val:?})"
100        ))
101    }
102}
103
104/// The opaque source reference is a producer-supplied identity for the decision in ITS source — a
105/// non-empty string (e.g. an issue/commit ref) or a non-empty structured object. ev NEVER interprets
106/// its contents: it is the adopter's concept (a "round", a ticket, a work-unit), carried opaquely.
107/// Only these two shapes are accepted; a bare number/bool/null/array is not a meaningful identity.
108pub(crate) fn validate_source_ref(v: &Value) -> Result<(), String> {
109    match v {
110        Value::String(s) if !s.is_empty() => Ok(()),
111        Value::String(_) => Err("source_ref string is empty".into()),
112        Value::Object(m) if !m.is_empty() => Ok(()),
113        Value::Object(_) => Err("source_ref object is empty".into()),
114        _ => Err("source_ref must be a non-empty string or object".into()),
115    }
116}
117
118/// The ONE thing ev derives from a source_ref: a stable scalar dedup/reconcile key. A string is its
119/// own key; an object's key is its deterministic JSON (serde_json's Map is a sorted BTreeMap with
120/// `preserve_order` off, so equal objects serialize identically). ev compares THESE keys, never the
121/// contents — so a producer that keeps its source identity stable gets idempotent re-imports.
122pub(crate) fn source_ref_key(v: &Value) -> String {
123    match v {
124        Value::String(s) => s.clone(),
125        other => other.to_string(),
126    }
127}
128
129/// Whether a detect-only (`C`/`D`) jurisdiction carries a runnable Test check on any ground. This is
130/// the single predicate behind the structural "a detect-only decision must not be able to gate, so it
131/// holds no runnable test binding" refusal — enforced both at the migrate ingest boundary (refuse at
132/// the door) and at-rest by `verify` (LOCK 2). One definition so the two sites can never drift.
133pub(crate) fn detect_only_carries_test(jurisdiction: Option<&str>, grounds: &[Ground]) -> bool {
134    matches!(jurisdiction, Some("C") | Some("D"))
135        && grounds
136            .iter()
137            .any(|g| matches!(g.check, Some(Check::Test { .. })))
138}
139
140pub(crate) fn only_keys(
141    obj: &Map<String, Value>,
142    allowed: &[&str],
143    what: &str,
144) -> Result<(), String> {
145    for k in obj.keys() {
146        if !allowed.contains(&k.as_str()) {
147            return Err(format!("{what}: field outside closed schema: {k}"));
148        }
149    }
150    Ok(())
151}
152
153pub(crate) fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
154    obj.get(k)
155        .and_then(|x| x.as_str())
156        .map(|s| s.to_string())
157        .ok_or(format!("missing or non-string field: {k}"))
158}
159
160pub(crate) fn is_40_lower_hex(s: &str) -> bool {
161    s.len() == 40
162        && s.bytes()
163            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
164}
165
166fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
167    let a = obj
168        .get(k)
169        .and_then(|x| x.as_array())
170        .ok_or(format!("liveness.{k} missing/not array"))?;
171    let mut out = Vec::new();
172    for e in a {
173        let s = e
174            .as_str()
175            .ok_or(format!("liveness.{k} element not a string"))?;
176        if s.is_empty() {
177            return Err(format!("liveness.{k} has an empty element"));
178        }
179        out.push(s.to_string());
180    }
181    if out.is_empty() {
182        return Err(format!("liveness.{k} must be non-empty"));
183    }
184    Ok(out)
185}
186
187fn check_from_value(v: &Value) -> Result<Check, String> {
188    let obj = v.as_object().ok_or("check is not an object")?;
189    match obj.get("by").and_then(|x| x.as_str()) {
190        Some("person") => {
191            only_keys(obj, &["by", "ref"], "person check")?;
192            let reference = req_str(obj, "ref")?;
193            if reference.is_empty() {
194                return Err("person check ref is empty".into());
195            }
196            Ok(Check::Person { reference })
197        }
198        Some("test") => {
199            only_keys(
200                obj,
201                &["by", "ref", "verified_at_sha", "counter_test", "liveness"],
202                "test check",
203            )?;
204            let reference = req_str(obj, "ref")?;
205            if reference.is_empty() {
206                return Err("test check ref is empty".into());
207            }
208            let verified_at_sha = req_str(obj, "verified_at_sha")?;
209            if !is_40_lower_hex(&verified_at_sha) {
210                return Err(format!(
211                    "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
212                ));
213            }
214            // counter_test is optional: absent = a harvested binding. When present it MUST be a
215            // non-empty string (req_str accepts "", so guard non-emptiness explicitly here).
216            let counter_test = match obj.get("counter_test") {
217                None => None,
218                Some(cv) => {
219                    let s = cv.as_str().ok_or("counter_test present but not a string")?;
220                    if s.is_empty() {
221                        return Err("counter_test present but empty".into());
222                    }
223                    Some(s.to_string())
224                }
225            };
226            let lv = obj
227                .get("liveness")
228                .and_then(|x| x.as_object())
229                .ok_or("liveness missing/not object")?;
230            only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
231            let liveness = Liveness {
232                platforms: nonempty_str_set(lv, "platforms")?,
233                triggered_by: nonempty_str_set(lv, "triggered_by")?,
234                surfaces: nonempty_str_set(lv, "surfaces")?,
235            };
236            Ok(Check::Test {
237                reference,
238                verified_at_sha,
239                counter_test,
240                liveness,
241            })
242        }
243        other => Err(format!(
244            "check.by must be \"test\" or \"person\", got {other:?}"
245        )),
246    }
247}
248
249pub(crate) fn ground_from_value(v: &Value) -> Result<Ground, String> {
250    let obj = v.as_object().ok_or("ground is not an object")?;
251    only_keys(obj, &["claim", "supports", "check"], "ground")?;
252    let claim = req_str(obj, "claim")?;
253    if claim.is_empty() {
254        return Err("ground claim is empty".into());
255    }
256    let supports = req_str(obj, "supports")?;
257    let ok_supports = supports == "chosen"
258        || (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
259    if !ok_supports {
260        return Err(format!("invalid supports: {supports}"));
261    }
262    let check = match obj.get("check") {
263        None => None,
264        Some(cv) => Some(check_from_value(cv)?),
265    };
266    Ok(Ground {
267        claim,
268        supports,
269        check,
270    })
271}
272
273/// The hashed/identity set: top-level keys that the schema is STRICT about. A tick whose payload
274/// has an unknown key INSIDE these (e.g. a stray key on a ground/check) is rejected by the nested
275/// strict `only_keys`; these names are also what `unknown_top_level_keys` excludes when surfacing
276/// a tolerated forward-compat key as a warning.
277pub(crate) const HASHED_TOP_LEVEL_KEYS: &[&str] = &[
278    "id",
279    "parent_id",
280    "observe",
281    "decision",
282    "grounds",
283    "status",
284    "held_since",
285    "blame",
286];
287
288/// The known-non-hashed allow-list: declared bookkeeping fields, validated but not hashed.
289pub(crate) const KNOWN_NON_HASHED_KEYS: &[&str] = &[
290    "authority",
291    "jurisdiction",
292    "source_ref",
293    "provenance",
294    "corrects",
295];
296
297/// Validate a `corrects` back-link: it must be a tick id — exactly 12 lowercase hex. (ev itself only
298/// ever writes a real target id via `ev correct`; the check catches a hand-edited/typo'd reference.)
299pub(crate) fn validate_corrects(val: &str) -> Result<(), String> {
300    if val.len() == 12
301        && val
302            .bytes()
303            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
304    {
305        Ok(())
306    } else {
307        Err(format!(
308            "corrects must be a 12-char lowercase-hex tick id (got {val:?})"
309        ))
310    }
311}
312
313/// A tick's top-level keys that are neither hashed/identity nor a known-non-hashed field — the
314/// truly-unknown, tolerated forward-compat keys (`from_value` parses them through; verify warns).
315pub(crate) fn unknown_top_level_keys(obj: &Map<String, Value>) -> Vec<String> {
316    obj.keys()
317        .filter(|k| {
318            !HASHED_TOP_LEVEL_KEYS.contains(&k.as_str())
319                && !KNOWN_NON_HASHED_KEYS.contains(&k.as_str())
320        })
321        .cloned()
322        .collect()
323}
324
325/// Strict parse of an on-disk tick — this IS the R1 (closed schema) + R2 (check shape) check.
326///
327/// Two-tier forward-compat (T3): the hashed/identity set (`HASHED_TOP_LEVEL_KEYS`) stays STRICT —
328/// a missing one is an Err, and the nested grounds/check schemas reject any unknown key inside the
329/// HASHED payload, so the content-addressed id can never carry an unvalidated field. The known
330/// non-hashed fields are validated. A truly-unknown OTHER top-level key is TOLERATED (parsed
331/// through, not rejected) so a newer writer's bookkeeping field does not brick an older reader;
332/// `ev verify` surfaces it as a `warning:` so a typo'd field name stays visible.
333pub fn from_value(v: &Value) -> Result<Tick, String> {
334    let obj = v.as_object().ok_or("tick is not an object")?;
335    let grounds_v = obj
336        .get("grounds")
337        .and_then(|x| x.as_array())
338        .ok_or("grounds missing/not array")?;
339    let mut grounds = Vec::new();
340    for gv in grounds_v {
341        grounds.push(ground_from_value(gv)?);
342    }
343    Ok(Tick {
344        id: req_str(obj, "id")?,
345        parent_id: req_str(obj, "parent_id")?,
346        observe: req_str(obj, "observe")?,
347        decision: req_str(obj, "decision")?,
348        grounds,
349        status: req_str(obj, "status")?,
350        held_since: req_str(obj, "held_since")?,
351        blame: req_str(obj, "blame")?,
352        authority: obj
353            .get("authority")
354            .and_then(|x| x.as_str())
355            .map(String::from),
356        jurisdiction: match obj.get("jurisdiction").and_then(|x| x.as_str()) {
357            None => None,
358            Some(j) => {
359                validate_jurisdiction(j)?; // out-of-vocab → Err
360                Some(j.to_string())
361            }
362        },
363        source_ref: match obj.get("source_ref") {
364            None => None,
365            Some(rv) => {
366                validate_source_ref(rv)?; // a non-empty string or object; ev never interprets it
367                Some(rv.clone())
368            }
369        },
370        provenance: match obj.get("provenance").and_then(|x| x.as_str()) {
371            None => None,
372            Some(p) => {
373                validate_provenance(p)?; // out-of-vocab → Err
374                Some(p.to_string())
375            }
376        },
377        corrects: match obj.get("corrects").and_then(|x| x.as_str()) {
378            None => None,
379            Some(c) => {
380                validate_corrects(c)?; // must be a 12-hex tick id
381                Some(c.to_string())
382            }
383        },
384    })
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use serde_json::json;
391
392    #[test]
393    fn the_only_relation_overlay_edge_is_corrects() {
394        // given/then: 0.1.10 ships exactly ONE relation-overlay edge — `corrects` — written by
395        // `ev correct` and read by the brief/list collapse. The general case-law graph (governed-by /
396        // case-of / arbitrary typed edges) is the PARKED 0.2 work and is deliberately NOT built. This
397        // fence pins the overlay surface: the non-hashed key set is exactly these five — four
398        // bookkeeping TAGS plus the ONE relation edge. Adding another overlay field (a new relation
399        // especially) must be a DELIBERATE decision that updates this fence, never an accident.
400        assert_eq!(
401            KNOWN_NON_HASHED_KEYS,
402            &[
403                "authority",
404                "jurisdiction",
405                "source_ref",
406                "provenance",
407                "corrects"
408            ],
409            "the relation-overlay surface changed — is this a deliberate new edge (the 0.2 graph)?"
410        );
411    }
412
413    #[test]
414    fn from_value_should_reject_a_corrects_that_is_not_a_tick_id() {
415        // given: an on-disk tick whose corrects edge is hand-edited to a non-id value
416        let mut v = genesis_full();
417        v.as_object_mut()
418            .unwrap()
419            .insert("corrects".into(), json!("not-a-real-id"));
420
421        // when/then: the read-path validator rejects it — the overlay edge must be a real tick id
422        assert!(from_value(&v).is_err());
423    }
424
425    #[test]
426    fn from_value_should_accept_a_valid_corrects_edge() {
427        // given: an on-disk tick carrying a well-formed 12-hex corrects edge
428        let mut v = genesis_full();
429        v.as_object_mut()
430            .unwrap()
431            .insert("corrects".into(), json!("638c47b0c9dd"));
432
433        // when/then: it parses and round-trips the edge
434        let t = from_value(&v).expect("a valid corrects edge parses");
435        assert_eq!(t.corrects.as_deref(), Some("638c47b0c9dd"));
436    }
437
438    #[test]
439    fn corrects_must_be_a_twelve_hex_tick_id() {
440        // given/then: the edge validates as a tick id — a hand-edited/typo'd reference is rejected
441        assert!(validate_corrects("e2b337f53a1f").is_ok());
442        assert!(validate_corrects("E2B337F53A1F").is_err()); // uppercase
443        assert!(validate_corrects("e2b337").is_err()); // too short
444        assert!(validate_corrects("not-hex-here!").is_err());
445    }
446
447    fn genesis_full() -> serde_json::Value {
448        json!({
449            "id": "e2b337f53a1f", "parent_id": "",
450            "observe": "o", "decision": "d",
451            "grounds": [{ "claim": "c", "supports": "chosen",
452                          "check": { "by": "person", "ref": "Q3 review" } }],
453            "status": "live", "held_since": "", "blame": "Wang Yu"
454        })
455    }
456
457    #[test]
458    fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
459        // given: a well-formed on-disk tick value
460        let v = genesis_full();
461
462        // when: it is parsed through from_value
463        let t = from_value(&v).expect("valid");
464
465        // then: the parsed fields and the person check are preserved
466        assert_eq!(t.decision, "d");
467        assert_eq!(t.grounds.len(), 1);
468        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
469    }
470
471    #[test]
472    fn from_value_should_round_trip_an_authority_tag_when_present() {
473        // given: a well-formed tick carrying an authority tag
474        let mut v = genesis_full();
475        v.as_object_mut()
476            .unwrap()
477            .insert("authority".into(), json!("user-ruled"));
478
479        // when: it is parsed
480        let t = from_value(&v).expect("valid");
481
482        // then: the authority tag is preserved
483        assert_eq!(t.authority.as_deref(), Some("user-ruled"));
484    }
485
486    #[test]
487    fn from_value_should_default_authority_to_none_when_absent() {
488        // given: a tick with no authority field (the existing genesis shape)
489        let v = genesis_full();
490
491        // when: it is parsed
492        let t = from_value(&v).expect("valid");
493
494        // then: authority is None (absent = no claim)
495        assert_eq!(t.authority, None);
496    }
497
498    #[test]
499    fn from_value_should_reject_the_tick_when_a_hashed_identity_field_is_missing() {
500        // given: a tick value missing a hashed/identity field (the strict tier stays closed)
501        let mut v = genesis_full();
502        v.as_object_mut().unwrap().remove("decision");
503
504        // when: it is parsed through from_value
505        let result = from_value(&v);
506
507        // then: parsing fails — the hashed/identity set is not forward-compat-tolerant
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
513        // given: a tick whose person check also carries a test-only liveness field
514        let mut v = genesis_full();
515        v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
516
517        // when: it is parsed through from_value
518        let result = from_value(&v);
519
520        // then: parsing fails
521        assert!(result.is_err());
522    }
523
524    #[test]
525    fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
526        // given: a tick with a test check whose verified_at_sha is not 40 lowercase hex
527        let mut v = genesis_full();
528        v["grounds"][0]["check"] = json!({
529            "by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
530            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
531        });
532
533        // when: it is parsed through from_value
534        let result = from_value(&v);
535
536        // then: parsing fails
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn from_value_should_reject_an_empty_counter_test_when_present() {
542        // given: a tick with a test check whose counter_test is present but empty
543        let mut v = genesis_full();
544        v["grounds"][0]["check"] = json!({
545            "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "",
546            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
547        });
548
549        // when: it is parsed through from_value
550        let result = from_value(&v);
551
552        // then: parsing fails (non-empty-if-present; req_str would have accepted "")
553        assert!(result.is_err());
554    }
555
556    #[test]
557    fn from_value_should_round_trip_a_harvested_test_check_when_counter_test_is_absent() {
558        // given: a tick with a test check that omits counter_test (a harvested binding)
559        let mut v = genesis_full();
560        v["grounds"][0]["check"] = json!({
561            "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
562            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
563        });
564
565        // when: it is parsed through from_value
566        let t = from_value(&v).expect("valid");
567
568        // then: the test check parses with counter_test None
569        assert!(matches!(
570            &t.grounds[0].check,
571            Some(Check::Test {
572                counter_test: None,
573                ..
574            })
575        ));
576    }
577
578    #[test]
579    fn from_value_should_round_trip_a_jurisdiction_tag_when_present() {
580        // given: a well-formed tick carrying a jurisdiction tag in the vocabulary
581        let mut v = genesis_full();
582        v.as_object_mut()
583            .unwrap()
584            .insert("jurisdiction".into(), json!("C"));
585
586        // when: it is parsed
587        let t = from_value(&v).expect("valid");
588
589        // then: the jurisdiction tag is preserved
590        assert_eq!(t.jurisdiction.as_deref(), Some("C"));
591    }
592
593    #[test]
594    fn from_value_should_default_jurisdiction_to_none_when_absent() {
595        // given: a tick with no jurisdiction field (the existing genesis shape)
596        let v = genesis_full();
597
598        // when: it is parsed
599        let t = from_value(&v).expect("valid");
600
601        // then: jurisdiction is None (absent = no claim)
602        assert_eq!(t.jurisdiction, None);
603    }
604
605    #[test]
606    fn from_value_should_round_trip_a_string_source_ref_when_present() {
607        // given: a well-formed tick carrying a bare-string opaque source_ref
608        let mut v = genesis_full();
609        v.as_object_mut()
610            .unwrap()
611            .insert("source_ref".into(), json!("R2289"));
612
613        // when: it is parsed
614        let t = from_value(&v).expect("valid");
615
616        // then: the source_ref is preserved verbatim (durable, non-hashed, opaque)
617        assert_eq!(t.source_ref, Some(json!("R2289")));
618    }
619
620    #[test]
621    fn from_value_should_round_trip_a_structured_source_ref_when_given_an_object() {
622        // given: a tick whose source_ref is a STRUCTURED object (richer than a string)
623        let mut v = genesis_full();
624        v.as_object_mut().unwrap().insert(
625            "source_ref".into(),
626            json!({"round": "R2289", "ticket": "#1194"}),
627        );
628
629        // when: it is parsed
630        let t = from_value(&v).expect("valid");
631
632        // then: the whole object is carried opaquely (ev never interprets its fields)
633        assert_eq!(
634            t.source_ref,
635            Some(json!({"round": "R2289", "ticket": "#1194"}))
636        );
637    }
638
639    #[test]
640    fn from_value_should_default_source_ref_to_none_when_absent() {
641        // given: a tick with no source_ref field (the existing genesis shape)
642        let v = genesis_full();
643
644        // when: it is parsed
645        let t = from_value(&v).expect("valid");
646
647        // then: source_ref is None (absent = no claim)
648        assert_eq!(t.source_ref, None);
649    }
650
651    #[test]
652    fn from_value_should_reject_an_empty_source_ref_when_present() {
653        // given: a tick whose source_ref is present but an empty string
654        let mut v = genesis_full();
655        v.as_object_mut()
656            .unwrap()
657            .insert("source_ref".into(), json!(""));
658
659        // when: it is parsed
660        let result = from_value(&v);
661
662        // then: parsing fails (an empty identity is no identity)
663        assert!(result.is_err());
664    }
665
666    #[test]
667    fn from_value_should_reject_a_non_string_non_object_source_ref() {
668        // given: a tick whose source_ref is a bare number (not a meaningful identity)
669        let mut v = genesis_full();
670        v.as_object_mut()
671            .unwrap()
672            .insert("source_ref".into(), json!(42));
673
674        // when: it is parsed
675        let result = from_value(&v);
676
677        // then: parsing fails (only a non-empty string or object is accepted)
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn from_value_should_round_trip_provenance_when_present() {
683        // given: a well-formed tick carrying a provenance tag in the vocabulary
684        let mut v = genesis_full();
685        v.as_object_mut()
686            .unwrap()
687            .insert("provenance".into(), json!("imported"));
688
689        // when: it is parsed
690        let t = from_value(&v).expect("valid");
691
692        // then: the provenance tag is preserved (declared, non-hashed)
693        assert_eq!(t.provenance.as_deref(), Some("imported"));
694    }
695
696    #[test]
697    fn from_value_should_default_provenance_to_none_when_absent() {
698        // given: a tick with no provenance field (the existing genesis shape = human-now)
699        let v = genesis_full();
700
701        // when: it is parsed
702        let t = from_value(&v).expect("valid");
703
704        // then: provenance is None (absent = human-now, no laundering possible)
705        assert_eq!(t.provenance, None);
706    }
707
708    #[test]
709    fn from_value_should_reject_an_out_of_vocab_provenance() {
710        // given: a tick whose provenance is outside the closed vocabulary
711        let mut v = genesis_full();
712        v.as_object_mut()
713            .unwrap()
714            .insert("provenance".into(), json!("self-asserted"));
715
716        // when: it is parsed
717        let result = from_value(&v);
718
719        // then: parsing fails (vocab-validated, like jurisdiction)
720        assert!(result.is_err());
721    }
722
723    #[test]
724    fn from_value_should_reject_an_out_of_vocab_jurisdiction() {
725        // given: a tick whose jurisdiction is outside the closed {A,B,C,D} vocabulary
726        let mut v = genesis_full();
727        v.as_object_mut()
728            .unwrap()
729            .insert("jurisdiction".into(), json!("Z"));
730
731        // when: it is parsed
732        let result = from_value(&v);
733
734        // then: parsing fails (vocab-validated, like authority)
735        assert!(result.is_err());
736    }
737
738    #[test]
739    fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
740        // given: a tick with a test check whose ref is empty
741        let mut v = genesis_full();
742        v["grounds"][0]["check"] = json!({
743            "by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
744            "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
745        });
746
747        // when: it is parsed through from_value
748        let result = from_value(&v);
749
750        // then: parsing fails
751        assert!(result.is_err());
752    }
753
754    #[test]
755    fn from_value_should_tolerate_an_unknown_non_hashed_key_when_reading() {
756        // given: a well-formed tick carrying a bogus extra top-level key (a forward-compat field)
757        let mut v = genesis_full();
758        v.as_object_mut()
759            .unwrap()
760            .insert("future_field".into(), json!("x"));
761
762        // when: it is parsed through from_value
763        let t = from_value(&v).expect("an unknown top-level key is tolerated (parsed-through)");
764
765        // then: the known fields are intact (the unknown key is ignored, not rejected)
766        assert_eq!(t.decision, "d");
767        assert_eq!(t.observe, "o");
768        assert_eq!(t.grounds.len(), 1);
769    }
770
771    #[test]
772    fn from_value_should_still_reject_an_unknown_key_inside_the_hashed_payload() {
773        // given: a well-formed tick whose ground (part of the hashed payload) carries an unknown key
774        let mut v = genesis_full();
775        v["grounds"][0]
776            .as_object_mut()
777            .unwrap()
778            .insert("future_field".into(), json!("x"));
779
780        // when: it is parsed through from_value
781        let result = from_value(&v);
782
783        // then: parsing fails — the hashed payload stays a strictly closed schema
784        assert!(result.is_err());
785    }
786}