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