1#[derive(Debug, Clone, PartialEq)]
5pub struct Tick {
6 pub id: String, pub parent_id: String, pub observe: String, pub decision: String, pub grounds: Vec<Ground>, pub status: String, pub held_since: String, pub blame: String, pub authority: Option<String>, pub jurisdiction: Option<String>, pub source_ref: Option<Value>, pub provenance: Option<String>, pub corrects: Option<String>, }
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct Ground {
23 pub claim: String,
24 pub supports: String, pub check: Option<Check>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum Check {
30 Person {
31 reference: String,
32 }, Test {
34 reference: String, verified_at_sha: String, counter_test: Option<String>, 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
51pub 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
78pub(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
89pub(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
104pub(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
118pub(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
129pub(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 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
273pub(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
288pub(crate) const KNOWN_NON_HASHED_KEYS: &[&str] = &[
290 "authority",
291 "jurisdiction",
292 "source_ref",
293 "provenance",
294 "corrects",
295];
296
297pub(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
313pub(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
325pub 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)?; 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)?; 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)?; 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)?; 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 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 let mut v = genesis_full();
417 v.as_object_mut()
418 .unwrap()
419 .insert("corrects".into(), json!("not-a-real-id"));
420
421 assert!(from_value(&v).is_err());
423 }
424
425 #[test]
426 fn from_value_should_accept_a_valid_corrects_edge() {
427 let mut v = genesis_full();
429 v.as_object_mut()
430 .unwrap()
431 .insert("corrects".into(), json!("638c47b0c9dd"));
432
433 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 assert!(validate_corrects("e2b337f53a1f").is_ok());
442 assert!(validate_corrects("E2B337F53A1F").is_err()); assert!(validate_corrects("e2b337").is_err()); 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 let v = genesis_full();
461
462 let t = from_value(&v).expect("valid");
464
465 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 let mut v = genesis_full();
475 v.as_object_mut()
476 .unwrap()
477 .insert("authority".into(), json!("user-ruled"));
478
479 let t = from_value(&v).expect("valid");
481
482 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 let v = genesis_full();
490
491 let t = from_value(&v).expect("valid");
493
494 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 let mut v = genesis_full();
502 v.as_object_mut().unwrap().remove("decision");
503
504 let result = from_value(&v);
506
507 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 let mut v = genesis_full();
515 v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
516
517 let result = from_value(&v);
519
520 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 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 let result = from_value(&v);
535
536 assert!(result.is_err());
538 }
539
540 #[test]
541 fn from_value_should_reject_an_empty_counter_test_when_present() {
542 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 let result = from_value(&v);
551
552 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 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 let t = from_value(&v).expect("valid");
567
568 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 let mut v = genesis_full();
582 v.as_object_mut()
583 .unwrap()
584 .insert("jurisdiction".into(), json!("C"));
585
586 let t = from_value(&v).expect("valid");
588
589 assert_eq!(t.jurisdiction.as_deref(), Some("C"));
591 }
592
593 #[test]
594 fn from_value_should_default_jurisdiction_to_none_when_absent() {
595 let v = genesis_full();
597
598 let t = from_value(&v).expect("valid");
600
601 assert_eq!(t.jurisdiction, None);
603 }
604
605 #[test]
606 fn from_value_should_round_trip_a_string_source_ref_when_present() {
607 let mut v = genesis_full();
609 v.as_object_mut()
610 .unwrap()
611 .insert("source_ref".into(), json!("R2289"));
612
613 let t = from_value(&v).expect("valid");
615
616 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 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 let t = from_value(&v).expect("valid");
631
632 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 let v = genesis_full();
643
644 let t = from_value(&v).expect("valid");
646
647 assert_eq!(t.source_ref, None);
649 }
650
651 #[test]
652 fn from_value_should_reject_an_empty_source_ref_when_present() {
653 let mut v = genesis_full();
655 v.as_object_mut()
656 .unwrap()
657 .insert("source_ref".into(), json!(""));
658
659 let result = from_value(&v);
661
662 assert!(result.is_err());
664 }
665
666 #[test]
667 fn from_value_should_reject_a_non_string_non_object_source_ref() {
668 let mut v = genesis_full();
670 v.as_object_mut()
671 .unwrap()
672 .insert("source_ref".into(), json!(42));
673
674 let result = from_value(&v);
676
677 assert!(result.is_err());
679 }
680
681 #[test]
682 fn from_value_should_round_trip_provenance_when_present() {
683 let mut v = genesis_full();
685 v.as_object_mut()
686 .unwrap()
687 .insert("provenance".into(), json!("imported"));
688
689 let t = from_value(&v).expect("valid");
691
692 assert_eq!(t.provenance.as_deref(), Some("imported"));
694 }
695
696 #[test]
697 fn from_value_should_default_provenance_to_none_when_absent() {
698 let v = genesis_full();
700
701 let t = from_value(&v).expect("valid");
703
704 assert_eq!(t.provenance, None);
706 }
707
708 #[test]
709 fn from_value_should_reject_an_out_of_vocab_provenance() {
710 let mut v = genesis_full();
712 v.as_object_mut()
713 .unwrap()
714 .insert("provenance".into(), json!("self-asserted"));
715
716 let result = from_value(&v);
718
719 assert!(result.is_err());
721 }
722
723 #[test]
724 fn from_value_should_reject_an_out_of_vocab_jurisdiction() {
725 let mut v = genesis_full();
727 v.as_object_mut()
728 .unwrap()
729 .insert("jurisdiction".into(), json!("Z"));
730
731 let result = from_value(&v);
733
734 assert!(result.is_err());
736 }
737
738 #[test]
739 fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
740 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 let result = from_value(&v);
749
750 assert!(result.is_err());
752 }
753
754 #[test]
755 fn from_value_should_tolerate_an_unknown_non_hashed_key_when_reading() {
756 let mut v = genesis_full();
758 v.as_object_mut()
759 .unwrap()
760 .insert("future_field".into(), json!("x"));
761
762 let t = from_value(&v).expect("an unknown top-level key is tolerated (parsed-through)");
764
765 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 let mut v = genesis_full();
775 v["grounds"][0]
776 .as_object_mut()
777 .unwrap()
778 .insert("future_field".into(), json!("x"));
779
780 let result = from_value(&v);
782
783 assert!(result.is_err());
785 }
786}