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>, }
19
20#[derive(Debug, Clone, PartialEq)]
21pub struct Ground {
22 pub claim: String,
23 pub supports: String, pub check: Option<Check>,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub enum Check {
29 Person {
30 reference: String,
31 }, Test {
33 reference: String, verified_at_sha: String, counter_test: Option<String>, 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
50pub 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
74pub(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
85pub(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
100pub(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
114pub(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
125pub(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 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
269pub(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
284pub(crate) const KNOWN_NON_HASHED_KEYS: &[&str] =
286 &["authority", "jurisdiction", "source_ref", "provenance"];
287
288pub(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
300pub 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)?; 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)?; 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)?; 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 let v = genesis_full();
374
375 let t = from_value(&v).expect("valid");
377
378 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 let mut v = genesis_full();
388 v.as_object_mut()
389 .unwrap()
390 .insert("authority".into(), json!("user-ruled"));
391
392 let t = from_value(&v).expect("valid");
394
395 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 let v = genesis_full();
403
404 let t = from_value(&v).expect("valid");
406
407 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 let mut v = genesis_full();
415 v.as_object_mut().unwrap().remove("decision");
416
417 let result = from_value(&v);
419
420 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 let mut v = genesis_full();
428 v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
429
430 let result = from_value(&v);
432
433 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 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 let result = from_value(&v);
448
449 assert!(result.is_err());
451 }
452
453 #[test]
454 fn from_value_should_reject_an_empty_counter_test_when_present() {
455 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 let result = from_value(&v);
464
465 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 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 let t = from_value(&v).expect("valid");
480
481 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 let mut v = genesis_full();
495 v.as_object_mut()
496 .unwrap()
497 .insert("jurisdiction".into(), json!("C"));
498
499 let t = from_value(&v).expect("valid");
501
502 assert_eq!(t.jurisdiction.as_deref(), Some("C"));
504 }
505
506 #[test]
507 fn from_value_should_default_jurisdiction_to_none_when_absent() {
508 let v = genesis_full();
510
511 let t = from_value(&v).expect("valid");
513
514 assert_eq!(t.jurisdiction, None);
516 }
517
518 #[test]
519 fn from_value_should_round_trip_a_string_source_ref_when_present() {
520 let mut v = genesis_full();
522 v.as_object_mut()
523 .unwrap()
524 .insert("source_ref".into(), json!("R2289"));
525
526 let t = from_value(&v).expect("valid");
528
529 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 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 let t = from_value(&v).expect("valid");
544
545 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 let v = genesis_full();
556
557 let t = from_value(&v).expect("valid");
559
560 assert_eq!(t.source_ref, None);
562 }
563
564 #[test]
565 fn from_value_should_reject_an_empty_source_ref_when_present() {
566 let mut v = genesis_full();
568 v.as_object_mut()
569 .unwrap()
570 .insert("source_ref".into(), json!(""));
571
572 let result = from_value(&v);
574
575 assert!(result.is_err());
577 }
578
579 #[test]
580 fn from_value_should_reject_a_non_string_non_object_source_ref() {
581 let mut v = genesis_full();
583 v.as_object_mut()
584 .unwrap()
585 .insert("source_ref".into(), json!(42));
586
587 let result = from_value(&v);
589
590 assert!(result.is_err());
592 }
593
594 #[test]
595 fn from_value_should_round_trip_provenance_when_present() {
596 let mut v = genesis_full();
598 v.as_object_mut()
599 .unwrap()
600 .insert("provenance".into(), json!("imported"));
601
602 let t = from_value(&v).expect("valid");
604
605 assert_eq!(t.provenance.as_deref(), Some("imported"));
607 }
608
609 #[test]
610 fn from_value_should_default_provenance_to_none_when_absent() {
611 let v = genesis_full();
613
614 let t = from_value(&v).expect("valid");
616
617 assert_eq!(t.provenance, None);
619 }
620
621 #[test]
622 fn from_value_should_reject_an_out_of_vocab_provenance() {
623 let mut v = genesis_full();
625 v.as_object_mut()
626 .unwrap()
627 .insert("provenance".into(), json!("self-asserted"));
628
629 let result = from_value(&v);
631
632 assert!(result.is_err());
634 }
635
636 #[test]
637 fn from_value_should_reject_an_out_of_vocab_jurisdiction() {
638 let mut v = genesis_full();
640 v.as_object_mut()
641 .unwrap()
642 .insert("jurisdiction".into(), json!("Z"));
643
644 let result = from_value(&v);
646
647 assert!(result.is_err());
649 }
650
651 #[test]
652 fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
653 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 let result = from_value(&v);
662
663 assert!(result.is_err());
665 }
666
667 #[test]
668 fn from_value_should_tolerate_an_unknown_non_hashed_key_when_reading() {
669 let mut v = genesis_full();
671 v.as_object_mut()
672 .unwrap()
673 .insert("future_field".into(), json!("x"));
674
675 let t = from_value(&v).expect("an unknown top-level key is tolerated (parsed-through)");
677
678 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 let mut v = genesis_full();
688 v["grounds"][0]
689 .as_object_mut()
690 .unwrap()
691 .insert("future_field".into(), json!("x"));
692
693 let result = from_value(&v);
695
696 assert!(result.is_err());
698 }
699}