1use std::collections::{BTreeMap, BTreeSet};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use sha2::{Digest, Sha256};
13
14use crate::bundle::FindingBundle;
15use crate::canonical;
16use crate::project::Project;
17
18pub const EVENT_SCHEMA: &str = "vela.event.v0.1";
19pub const NULL_HASH: &str = "sha256:null";
20
21pub const EVENT_KIND_KEY_REVOKE: &str = "key.revoke";
34
35pub const EVENT_KIND_NEGATIVE_RESULT_ASSERTED: &str = "negative_result.asserted";
42pub const EVENT_KIND_NEGATIVE_RESULT_REVIEWED: &str = "negative_result.reviewed";
43pub const EVENT_KIND_NEGATIVE_RESULT_RETRACTED: &str = "negative_result.retracted";
44
45pub const EVENT_KIND_TRAJECTORY_CREATED: &str = "trajectory.created";
52pub const EVENT_KIND_TRAJECTORY_STEP_APPENDED: &str = "trajectory.step_appended";
53pub const EVENT_KIND_TRAJECTORY_REVIEWED: &str = "trajectory.reviewed";
54pub const EVENT_KIND_TRAJECTORY_RETRACTED: &str = "trajectory.retracted";
55
56pub const EVENT_KIND_ARTIFACT_ASSERTED: &str = "artifact.asserted";
60pub const EVENT_KIND_ARTIFACT_REVIEWED: &str = "artifact.reviewed";
61pub const EVENT_KIND_ARTIFACT_RETRACTED: &str = "artifact.retracted";
62
63pub const EVENT_KIND_TIER_SET: &str = "tier.set";
69
70pub const EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED: &str = "evidence_atom.locator_repaired";
84
85pub const EVENT_KIND_FINDING_SPAN_REPAIRED: &str = "finding.span_repaired";
91
92pub const EVENT_KIND_FINDING_ENTITY_RESOLVED: &str = "finding.entity_resolved";
99
100pub const EVENT_KIND_ATTESTATION_RECORDED: &str = "attestation.recorded";
118
119pub const EVENT_KIND_FINDING_ENTITY_ADDED: &str = "finding.entity_added";
138
139pub const EVENT_KIND_REPLICATION_DEPOSITED: &str = "replication.deposited";
150
151pub const EVENT_KIND_PREDICTION_DEPOSITED: &str = "prediction.deposited";
156
157pub const EVENT_KIND_BRIDGE_REVIEWED: &str = "bridge.reviewed";
165
166pub const EVENT_KIND_FRONTIER_CONFLICT_RESOLVED: &str = "frontier.conflict_resolved";
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct StateTarget {
186 pub r#type: String,
187 pub id: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct StateActor {
192 pub id: String,
193 pub r#type: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct StateEvent {
198 #[serde(default = "default_schema")]
199 pub schema: String,
200 pub id: String,
201 pub kind: String,
202 pub target: StateTarget,
203 pub actor: StateActor,
204 pub timestamp: String,
205 pub reason: String,
206 pub before_hash: String,
207 pub after_hash: String,
208 #[serde(default)]
209 pub payload: Value,
210 #[serde(default)]
211 pub caveats: Vec<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub signature: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub schema_artifact_id: Option<String>,
223}
224
225pub struct FindingEventInput<'a> {
226 pub kind: &'a str,
227 pub finding_id: &'a str,
228 pub actor_id: &'a str,
229 pub actor_type: &'a str,
230 pub reason: &'a str,
231 pub before_hash: &'a str,
232 pub after_hash: &'a str,
233 pub payload: Value,
234 pub caveats: Vec<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct EventLogSummary {
239 pub count: usize,
240 pub kinds: BTreeMap<String, usize>,
241 pub first_timestamp: Option<String>,
242 pub last_timestamp: Option<String>,
243 pub duplicate_ids: Vec<String>,
244 pub orphan_targets: Vec<String>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ReplayReport {
249 pub ok: bool,
250 pub status: String,
251 pub event_log: EventLogSummary,
252 pub source_hash: String,
253 pub event_log_hash: String,
254 pub replayed_hash: String,
255 pub current_hash: String,
256 pub conflicts: Vec<String>,
257}
258
259fn default_schema() -> String {
260 EVENT_SCHEMA.to_string()
261}
262
263pub fn new_finding_event(input: FindingEventInput<'_>) -> StateEvent {
264 let timestamp = Utc::now().to_rfc3339();
265 let mut event = StateEvent {
266 schema: EVENT_SCHEMA.to_string(),
267 id: String::new(),
268 kind: input.kind.to_string(),
269 target: StateTarget {
270 r#type: "finding".to_string(),
271 id: input.finding_id.to_string(),
272 },
273 actor: StateActor {
274 id: input.actor_id.to_string(),
275 r#type: input.actor_type.to_string(),
276 },
277 timestamp,
278 reason: input.reason.to_string(),
279 before_hash: input.before_hash.to_string(),
280 after_hash: input.after_hash.to_string(),
281 payload: input.payload,
282 caveats: input.caveats,
283 signature: None,
284 schema_artifact_id: None,
285 };
286 event.id = event_id(&event);
287 event
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299pub struct RevocationPayload {
300 pub revoked_pubkey: String,
302 pub revoked_at: String,
306 #[serde(default, skip_serializing_if = "String::is_empty")]
310 pub replacement_pubkey: String,
311 #[serde(default, skip_serializing_if = "String::is_empty")]
315 pub reason: String,
316}
317
318pub fn new_revocation_event(
324 actor_id: &str,
325 actor_type: &str,
326 payload: RevocationPayload,
327 reason: &str,
328 before_hash: &str,
329 after_hash: &str,
330) -> StateEvent {
331 let timestamp = Utc::now().to_rfc3339();
332 let payload_value =
333 serde_json::to_value(&payload).expect("RevocationPayload serializes to a JSON object");
334 let mut event = StateEvent {
335 schema: EVENT_SCHEMA.to_string(),
336 id: String::new(),
337 kind: EVENT_KIND_KEY_REVOKE.to_string(),
338 target: StateTarget {
339 r#type: "actor".to_string(),
340 id: actor_id.to_string(),
341 },
342 actor: StateActor {
343 id: actor_id.to_string(),
344 r#type: actor_type.to_string(),
345 },
346 timestamp,
347 reason: reason.to_string(),
348 before_hash: before_hash.to_string(),
349 after_hash: after_hash.to_string(),
350 payload: payload_value,
351 caveats: Vec::new(),
352 signature: None,
353 schema_artifact_id: None,
354 };
355 event.id = event_id(&event);
356 event
357}
358
359pub fn new_evidence_atom_locator_repair_event(
365 atom_id: &str,
366 actor_id: &str,
367 actor_type: &str,
368 reason: &str,
369 before_hash: &str,
370 after_hash: &str,
371 payload: Value,
372 caveats: Vec<String>,
373) -> StateEvent {
374 let timestamp = Utc::now().to_rfc3339();
375 let mut event = StateEvent {
376 schema: EVENT_SCHEMA.to_string(),
377 id: String::new(),
378 kind: EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED.to_string(),
379 target: StateTarget {
380 r#type: "evidence_atom".to_string(),
381 id: atom_id.to_string(),
382 },
383 actor: StateActor {
384 id: actor_id.to_string(),
385 r#type: actor_type.to_string(),
386 },
387 timestamp,
388 reason: reason.to_string(),
389 before_hash: before_hash.to_string(),
390 after_hash: after_hash.to_string(),
391 payload,
392 caveats,
393 signature: None,
394 schema_artifact_id: None,
395 };
396 event.id = event_id(&event);
397 event
398}
399
400pub fn new_frontier_conflict_resolved_event(
405 frontier_id: &str,
406 actor_id: &str,
407 actor_type: &str,
408 reason: &str,
409 payload: Value,
410 caveats: Vec<String>,
411) -> StateEvent {
412 let timestamp = Utc::now().to_rfc3339();
413 let mut event = StateEvent {
414 schema: EVENT_SCHEMA.to_string(),
415 id: String::new(),
416 kind: EVENT_KIND_FRONTIER_CONFLICT_RESOLVED.to_string(),
417 target: StateTarget {
418 r#type: "frontier_observation".to_string(),
419 id: frontier_id.to_string(),
420 },
421 actor: StateActor {
422 id: actor_id.to_string(),
423 r#type: actor_type.to_string(),
424 },
425 timestamp,
426 reason: reason.to_string(),
427 before_hash: NULL_HASH.to_string(),
428 after_hash: NULL_HASH.to_string(),
429 payload,
430 caveats,
431 signature: None,
432 schema_artifact_id: None,
433 };
434 event.id = event_id(&event);
435 event
436}
437
438pub fn new_bridge_reviewed_event(
442 bridge_id: &str,
443 actor_id: &str,
444 actor_type: &str,
445 reason: &str,
446 payload: Value,
447 caveats: Vec<String>,
448) -> StateEvent {
449 let timestamp = Utc::now().to_rfc3339();
450 let mut event = StateEvent {
451 schema: EVENT_SCHEMA.to_string(),
452 id: String::new(),
453 kind: EVENT_KIND_BRIDGE_REVIEWED.to_string(),
454 target: StateTarget {
455 r#type: "bridge".to_string(),
456 id: bridge_id.to_string(),
457 },
458 actor: StateActor {
459 id: actor_id.to_string(),
460 r#type: actor_type.to_string(),
461 },
462 timestamp,
463 reason: reason.to_string(),
464 before_hash: NULL_HASH.to_string(),
465 after_hash: NULL_HASH.to_string(),
466 payload,
467 caveats,
468 signature: None,
469 schema_artifact_id: None,
470 };
471 event.id = event_id(&event);
472 event
473}
474
475pub fn evidence_atom_hash(atom: &crate::sources::EvidenceAtom) -> String {
480 let bytes = canonical::to_canonical_bytes(atom).unwrap_or_default();
481 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
482}
483
484pub fn evidence_atom_hash_by_id(frontier: &Project, atom_id: &str) -> String {
485 frontier
486 .evidence_atoms
487 .iter()
488 .find(|atom| atom.id == atom_id)
489 .map(evidence_atom_hash)
490 .unwrap_or_else(|| NULL_HASH.to_string())
491}
492
493pub fn finding_hash(finding: &FindingBundle) -> String {
494 let mut hashable = finding.clone();
505 hashable.links.clear();
506 let bytes = canonical::to_canonical_bytes(&hashable).unwrap_or_default();
507 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
508}
509
510pub fn finding_hash_by_id(frontier: &Project, finding_id: &str) -> String {
511 frontier
512 .findings
513 .iter()
514 .find(|finding| finding.id == finding_id)
515 .map(finding_hash)
516 .unwrap_or_else(|| NULL_HASH.to_string())
517}
518
519pub fn event_log_hash(events: &[StateEvent]) -> String {
520 let bytes = canonical::to_canonical_bytes(events).unwrap_or_default();
521 hex::encode(Sha256::digest(bytes))
522}
523
524pub fn snapshot_hash(frontier: &Project) -> String {
525 let value = serde_json::to_value(frontier).unwrap_or(Value::Null);
526 let mut value = value;
527 if let Value::Object(map) = &mut value {
528 map.remove("events");
529 map.remove("signatures");
530 map.remove("proof_state");
531 }
532 let bytes = canonical::to_canonical_bytes(&value).unwrap_or_default();
533 hex::encode(Sha256::digest(bytes))
534}
535
536pub fn events_for_finding<'a>(frontier: &'a Project, finding_id: &str) -> Vec<&'a StateEvent> {
537 frontier
538 .events
539 .iter()
540 .filter(|event| event.target.r#type == "finding" && event.target.id == finding_id)
541 .collect()
542}
543
544pub fn replay_report(frontier: &Project) -> ReplayReport {
545 let event_log = summarize(frontier);
546 let mut conflicts = Vec::new();
547
548 if frontier.events.is_empty() {
549 let current_hash = snapshot_hash(frontier);
550 return ReplayReport {
551 ok: true,
552 status: "no_events".to_string(),
553 event_log,
554 source_hash: current_hash.clone(),
555 event_log_hash: event_log_hash(&frontier.events),
556 replayed_hash: current_hash.clone(),
557 current_hash,
558 conflicts,
559 };
560 }
561
562 for duplicate in &event_log.duplicate_ids {
563 conflicts.push(format!("duplicate event id: {duplicate}"));
564 }
565 for orphan in &event_log.orphan_targets {
566 conflicts.push(format!("orphan event target: {orphan}"));
567 }
568
569 let mut chains = BTreeMap::<String, Vec<&StateEvent>>::new();
570 for event in &frontier.events {
571 if event.schema != EVENT_SCHEMA {
572 conflicts.push(format!(
573 "unsupported event schema for {}: {}",
574 event.id, event.schema
575 ));
576 }
577 if event.reason.trim().is_empty() {
578 conflicts.push(format!("event {} has empty reason", event.id));
579 }
580 if event.before_hash.trim().is_empty() || event.after_hash.trim().is_empty() {
581 conflicts.push(format!("event {} has empty hash boundary", event.id));
582 }
583 if let Err(err) = validate_event_payload(&event.kind, &event.payload) {
588 conflicts.push(format!("event {} payload invalid: {err}", event.id));
589 }
590 chains
591 .entry(format!("{}:{}", event.target.r#type, event.target.id))
592 .or_default()
593 .push(event);
594 }
595
596 for (target, events) in chains {
597 let mut sorted = events;
598 sorted.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)));
599 for pair in sorted.windows(2) {
600 let previous = pair[0];
601 let next = pair[1];
602 if previous.after_hash != next.before_hash {
603 conflicts.push(format!(
604 "event chain break for {target}: {} after_hash does not match {} before_hash",
605 previous.id, next.id
606 ));
607 }
608 }
609 if let Some(last) = sorted.last()
610 && last.target.r#type == "finding"
611 {
612 let current = finding_hash_by_id(frontier, &last.target.id);
613 if current != last.after_hash {
614 conflicts.push(format!(
615 "materialized finding {} hash does not match last event {}",
616 last.target.id, last.id
617 ));
618 }
619 }
620 }
621
622 let current_hash = snapshot_hash(frontier);
623 let ok = conflicts.is_empty();
624 ReplayReport {
625 ok,
626 status: if ok { "ok" } else { "conflict" }.to_string(),
627 event_log,
628 source_hash: current_hash.clone(),
629 event_log_hash: event_log_hash(&frontier.events),
630 replayed_hash: if ok {
631 current_hash.clone()
632 } else {
633 "unavailable".to_string()
634 },
635 current_hash,
636 conflicts,
637 }
638}
639
640pub fn replay_report_json(frontier: &Project) -> Value {
641 serde_json::to_value(replay_report(frontier)).unwrap_or_else(|_| json!({"ok": false}))
642}
643
644pub fn summarize(frontier: &Project) -> EventLogSummary {
645 let mut kinds = BTreeMap::<String, usize>::new();
646 let mut seen = BTreeSet::<String>::new();
647 let mut duplicate_ids = BTreeSet::<String>::new();
648 let finding_ids = frontier
649 .findings
650 .iter()
651 .map(|finding| finding.id.as_str())
652 .collect::<BTreeSet<_>>();
653 let mut orphan_targets = BTreeSet::<String>::new();
654 let mut timestamps = Vec::<String>::new();
655
656 for event in &frontier.events {
657 *kinds.entry(event.kind.clone()).or_default() += 1;
658 if !seen.insert(event.id.clone()) {
659 duplicate_ids.insert(event.id.clone());
660 }
661 if event.target.r#type == "finding"
662 && !finding_ids.contains(event.target.id.as_str())
663 && event.kind != "finding.retracted"
664 {
665 orphan_targets.insert(event.target.id.clone());
666 }
667 timestamps.push(event.timestamp.clone());
668 }
669 timestamps.sort();
670
671 EventLogSummary {
672 count: frontier.events.len(),
673 kinds,
674 first_timestamp: timestamps.first().cloned(),
675 last_timestamp: timestamps.last().cloned(),
676 duplicate_ids: duplicate_ids.into_iter().collect(),
677 orphan_targets: orphan_targets.into_iter().collect(),
678 }
679}
680
681fn validate_sha256_commitment(field: &str, value: &str) -> Result<(), String> {
692 let hex = value.strip_prefix("sha256:").unwrap_or(value);
693 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
694 return Err(format!("{field} must be sha256:<64hex>"));
695 }
696 Ok(())
697}
698
699pub fn validate_event_payload(kind: &str, payload: &Value) -> Result<(), String> {
700 let object = payload.as_object().ok_or_else(|| {
701 if matches!(payload, Value::Null) {
702 "payload must be a JSON object (got null)".to_string()
703 } else {
704 "payload must be a JSON object".to_string()
705 }
706 })?;
707 let require_str = |key: &str| -> Result<&str, String> {
708 object
709 .get(key)
710 .and_then(Value::as_str)
711 .ok_or_else(|| format!("missing required string field '{key}'"))
712 };
713 let require_f64 = |key: &str| -> Result<f64, String> {
714 object
715 .get(key)
716 .and_then(Value::as_f64)
717 .ok_or_else(|| format!("missing required number field '{key}'"))
718 };
719 match kind {
720 "finding.asserted" => {
721 require_str("proposal_id")?;
724 }
725 "finding.reviewed" => {
726 require_str("proposal_id")?;
727 let status = require_str("status")?;
728 if !matches!(
729 status,
730 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
731 ) {
732 return Err(format!("invalid review status '{status}'"));
733 }
734 }
735 "finding.noted" | "finding.caveated" => {
736 require_str("proposal_id")?;
737 require_str("annotation_id")?;
738 let text = require_str("text")?;
739 if text.trim().is_empty() {
740 return Err("payload.text must be non-empty".to_string());
741 }
742 if let Some(prov) = object.get("provenance") {
748 let prov_obj = prov
749 .as_object()
750 .ok_or("payload.provenance must be a JSON object when present")?;
751 let has_id = prov_obj
752 .get("doi")
753 .and_then(Value::as_str)
754 .is_some_and(|s| !s.trim().is_empty())
755 || prov_obj
756 .get("pmid")
757 .and_then(Value::as_str)
758 .is_some_and(|s| !s.trim().is_empty())
759 || prov_obj
760 .get("title")
761 .and_then(Value::as_str)
762 .is_some_and(|s| !s.trim().is_empty());
763 if !has_id {
764 return Err(
765 "payload.provenance must include at least one of doi/pmid/title"
766 .to_string(),
767 );
768 }
769 }
770 }
771 "finding.confidence_revised" => {
772 require_str("proposal_id")?;
773 let new_score = require_f64("new_score")?;
774 if !(0.0..=1.0).contains(&new_score) {
775 return Err(format!("new_score {new_score} out of [0.0, 1.0]"));
776 }
777 let _ = require_f64("previous_score")?;
778 }
779 "finding.rejected" => {
780 require_str("proposal_id")?;
781 }
782 "finding.superseded" => {
783 require_str("proposal_id")?;
784 require_str("new_finding_id")?;
785 }
786 "finding.retracted" => {
787 require_str("proposal_id")?;
788 if let Some(affected) = object.get("affected") {
791 let _ = affected
792 .as_u64()
793 .ok_or("affected must be a non-negative integer")?;
794 }
795 }
796 "finding.dependency_invalidated" => {
801 require_str("upstream_finding_id")?;
802 require_str("upstream_event_id")?;
803 let depth = object
804 .get("depth")
805 .and_then(Value::as_u64)
806 .ok_or("missing required positive integer 'depth'")?;
807 if depth == 0 {
808 return Err("depth must be >= 1 (genesis is the source retraction)".to_string());
809 }
810 require_str("proposal_id")?;
812 }
813 "frontier.created" => {
816 require_str("name")?;
817 require_str("creator")?;
818 }
819 "prediction.expired_unresolved" => {
826 require_str("prediction_id")?;
827 require_str("resolves_by")?;
828 require_str("expired_at")?;
829 }
830 "frontier.synced_with_peer" => {
836 require_str("peer_id")?;
837 require_str("peer_snapshot_hash")?;
838 require_str("our_snapshot_hash")?;
839 let _ = object
840 .get("divergence_count")
841 .and_then(Value::as_u64)
842 .ok_or("missing required non-negative integer 'divergence_count'")?;
843 }
844 "frontier.conflict_detected" => {
845 require_str("peer_id")?;
846 require_str("finding_id")?;
847 let kind = require_str("kind")?;
848 if kind.trim().is_empty() {
853 return Err("payload.kind must be a non-empty string".to_string());
854 }
855 }
856 "frontier.conflict_resolved" => {
860 let conflict_event_id = require_str("conflict_event_id")?;
861 if conflict_event_id.trim().is_empty() {
862 return Err("payload.conflict_event_id must be a non-empty string".to_string());
863 }
864 let resolved_by = require_str("resolved_by")?;
865 if resolved_by.trim().is_empty() {
866 return Err("payload.resolved_by must be a non-empty string".to_string());
867 }
868 let note = require_str("resolution_note")?;
869 if note.trim().is_empty() {
870 return Err("payload.resolution_note must be a non-empty string".to_string());
871 }
872 if let Some(value) = object.get("winning_proposal_id")
876 && !value.is_null()
877 && !value.is_string()
878 {
879 return Err("payload.winning_proposal_id must be a string when present".to_string());
880 }
881 }
882 "replication.deposited" => {
887 let rep = object
888 .get("replication")
889 .ok_or("payload.replication is required")?;
890 if !rep.is_object() {
891 return Err("payload.replication must be an object".to_string());
892 }
893 let id = rep
894 .get("id")
895 .and_then(Value::as_str)
896 .ok_or("payload.replication.id is required (vrep_<hex>)")?;
897 if !id.starts_with("vrep_") {
898 return Err(format!(
899 "payload.replication.id must start with 'vrep_', got '{id}'"
900 ));
901 }
902 }
903 "prediction.deposited" => {
906 let pred = object
907 .get("prediction")
908 .ok_or("payload.prediction is required")?;
909 if !pred.is_object() {
910 return Err("payload.prediction must be an object".to_string());
911 }
912 let id = pred
913 .get("id")
914 .and_then(Value::as_str)
915 .ok_or("payload.prediction.id is required (vpred_<hex>)")?;
916 if !id.starts_with("vpred_") {
917 return Err(format!(
918 "payload.prediction.id must start with 'vpred_', got '{id}'"
919 ));
920 }
921 }
922 "bridge.reviewed" => {
929 let bridge_id = require_str("bridge_id")?;
930 if !bridge_id.starts_with("vbr_") {
931 return Err(format!(
932 "payload.bridge_id must start with 'vbr_', got '{bridge_id}'"
933 ));
934 }
935 let status = require_str("status")?;
936 if !matches!(status, "confirmed" | "refuted") {
937 return Err(format!(
938 "payload.status must be 'confirmed' or 'refuted', got '{status}'"
939 ));
940 }
941 if let Some(value) = object.get("note")
943 && !value.is_null()
944 && !value.is_string()
945 {
946 return Err("payload.note must be a string when present".to_string());
947 }
948 }
949 "assertion.reinterpreted_causal" => {
958 require_str("proposal_id")?;
959 let check_block = |block_name: &str| -> Result<(), String> {
960 let block = object
961 .get(block_name)
962 .and_then(Value::as_object)
963 .ok_or_else(|| format!("payload.{block_name} must be an object"))?;
964 if let Some(claim) = block.get("claim").and_then(Value::as_str)
965 && !crate::bundle::VALID_CAUSAL_CLAIMS.contains(&claim)
966 {
967 return Err(format!(
968 "{block_name}.claim '{claim}' not in {:?}",
969 crate::bundle::VALID_CAUSAL_CLAIMS
970 ));
971 }
972 if let Some(grade) = block.get("grade").and_then(Value::as_str)
973 && !crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES.contains(&grade)
974 {
975 return Err(format!(
976 "{block_name}.grade '{grade}' not in {:?}",
977 crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES
978 ));
979 }
980 Ok(())
981 };
982 check_block("before")?;
983 check_block("after")?;
984 }
985 "finding.threshold_set" => {
991 let threshold = object
992 .get("threshold")
993 .and_then(Value::as_u64)
994 .ok_or("missing required positive integer 'threshold'")?;
995 if threshold == 0 {
996 return Err("threshold must be >= 1".to_string());
997 }
998 }
999 "finding.threshold_met" => {
1000 let count = object
1001 .get("signature_count")
1002 .and_then(Value::as_u64)
1003 .ok_or("missing required positive integer 'signature_count'")?;
1004 let threshold = object
1005 .get("threshold")
1006 .and_then(Value::as_u64)
1007 .ok_or("missing required positive integer 'threshold'")?;
1008 if count < threshold {
1009 return Err(format!(
1010 "signature_count {count} below threshold {threshold}"
1011 ));
1012 }
1013 }
1014 EVENT_KIND_KEY_REVOKE => {
1022 let revoked = require_str("revoked_pubkey")?;
1023 if revoked.len() != 64 || !revoked.chars().all(|c| c.is_ascii_hexdigit()) {
1024 return Err(format!(
1025 "revoked_pubkey must be 64 hex chars (Ed25519 pubkey), got {} chars",
1026 revoked.len()
1027 ));
1028 }
1029 let revoked_at = require_str("revoked_at")?;
1030 if revoked_at.trim().is_empty() {
1031 return Err("revoked_at must be a non-empty ISO-8601 timestamp".to_string());
1032 }
1033 if DateTime::parse_from_rfc3339(revoked_at).is_err() {
1038 return Err(format!(
1039 "revoked_at must parse as RFC-3339/ISO-8601, got {revoked_at:?}"
1040 ));
1041 }
1042 if let Some(replacement) = object.get("replacement_pubkey")
1046 && let Some(rep_str) = replacement.as_str()
1047 && !rep_str.is_empty()
1048 && (rep_str.len() != 64 || !rep_str.chars().all(|c| c.is_ascii_hexdigit()))
1049 {
1050 return Err(format!(
1051 "replacement_pubkey must be 64 hex chars when present, got {} chars",
1052 rep_str.len()
1053 ));
1054 }
1055 if let Some(replacement) = object.get("replacement_pubkey").and_then(Value::as_str)
1058 && !replacement.is_empty()
1059 && replacement.eq_ignore_ascii_case(revoked)
1060 {
1061 return Err("replacement_pubkey must differ from revoked_pubkey".to_string());
1062 }
1063 }
1064 EVENT_KIND_NEGATIVE_RESULT_ASSERTED => {
1073 require_str("proposal_id")?;
1074 let nr = object
1075 .get("negative_result")
1076 .and_then(Value::as_object)
1077 .ok_or("payload.negative_result must be a JSON object")?;
1078 let nr_kind = nr
1079 .get("kind")
1080 .and_then(|k| k.as_object())
1081 .and_then(|k| k.get("kind"))
1082 .and_then(Value::as_str)
1083 .ok_or(
1084 "payload.negative_result.kind.kind must be 'registered_trial' or 'exploratory'",
1085 )?;
1086 match nr_kind {
1087 "registered_trial" => {
1088 let kind_obj = nr
1089 .get("kind")
1090 .and_then(Value::as_object)
1091 .expect("checked above");
1092 for k in ["endpoint", "intervention", "comparator", "population"] {
1093 let v = kind_obj
1094 .get(k)
1095 .and_then(Value::as_str)
1096 .ok_or_else(|| format!("registered_trial.{k} must be a string"))?;
1097 if v.trim().is_empty() {
1098 return Err(format!("registered_trial.{k} must be non-empty"));
1099 }
1100 }
1101 let _ = kind_obj
1102 .get("n_enrolled")
1103 .and_then(Value::as_u64)
1104 .ok_or("registered_trial.n_enrolled must be a non-negative integer")?;
1105 let power = kind_obj
1106 .get("power")
1107 .and_then(Value::as_f64)
1108 .ok_or("registered_trial.power must be a number on [0, 1]")?;
1109 if !(0.0..=1.0).contains(&power) {
1110 return Err(format!("registered_trial.power {power} out of [0.0, 1.0]"));
1111 }
1112 let ci = kind_obj
1113 .get("effect_size_ci")
1114 .and_then(Value::as_array)
1115 .ok_or("registered_trial.effect_size_ci must be a 2-element array [lower, upper]")?;
1116 if ci.len() != 2 {
1117 return Err(format!(
1118 "registered_trial.effect_size_ci must have length 2, got {}",
1119 ci.len()
1120 ));
1121 }
1122 let lower = ci[0]
1123 .as_f64()
1124 .ok_or("registered_trial.effect_size_ci[0] must be a number")?;
1125 let upper = ci[1]
1126 .as_f64()
1127 .ok_or("registered_trial.effect_size_ci[1] must be a number")?;
1128 if upper < lower {
1129 return Err(format!(
1130 "registered_trial.effect_size_ci upper {upper} below lower {lower}"
1131 ));
1132 }
1133 }
1134 "exploratory" => {
1135 let kind_obj = nr
1136 .get("kind")
1137 .and_then(Value::as_object)
1138 .expect("checked above");
1139 for k in ["reagent", "observation"] {
1140 let v = kind_obj
1141 .get(k)
1142 .and_then(Value::as_str)
1143 .ok_or_else(|| format!("exploratory.{k} must be a string"))?;
1144 if v.trim().is_empty() {
1145 return Err(format!("exploratory.{k} must be non-empty"));
1146 }
1147 }
1148 let attempts = kind_obj
1149 .get("attempts")
1150 .and_then(Value::as_u64)
1151 .ok_or("exploratory.attempts must be a positive integer")?;
1152 if attempts == 0 {
1153 return Err("exploratory.attempts must be >= 1".to_string());
1154 }
1155 }
1156 other => {
1157 return Err(format!(
1158 "negative_result.kind.kind '{other}' must be 'registered_trial' or 'exploratory'"
1159 ));
1160 }
1161 }
1162 let depositor = nr
1163 .get("deposited_by")
1164 .and_then(Value::as_str)
1165 .ok_or("payload.negative_result.deposited_by must be a non-empty string")?;
1166 if depositor.trim().is_empty() {
1167 return Err("payload.negative_result.deposited_by must be non-empty".to_string());
1168 }
1169 }
1170 EVENT_KIND_NEGATIVE_RESULT_REVIEWED => {
1171 require_str("proposal_id")?;
1172 let status = require_str("status")?;
1173 if !matches!(
1174 status,
1175 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1176 ) {
1177 return Err(format!("invalid review status '{status}'"));
1178 }
1179 }
1180 EVENT_KIND_NEGATIVE_RESULT_RETRACTED => {
1181 require_str("proposal_id")?;
1182 }
1183 EVENT_KIND_TRAJECTORY_CREATED => {
1189 require_str("proposal_id")?;
1190 let traj = object
1191 .get("trajectory")
1192 .and_then(Value::as_object)
1193 .ok_or("payload.trajectory must be a JSON object")?;
1194 let depositor = traj
1195 .get("deposited_by")
1196 .and_then(Value::as_str)
1197 .ok_or("payload.trajectory.deposited_by must be a non-empty string")?;
1198 if depositor.trim().is_empty() {
1199 return Err("payload.trajectory.deposited_by must be non-empty".to_string());
1200 }
1201 let id = traj
1202 .get("id")
1203 .and_then(Value::as_str)
1204 .ok_or("payload.trajectory.id must be a vtr_<hex>")?;
1205 if !id.starts_with("vtr_") {
1206 return Err(format!(
1207 "payload.trajectory.id must start with 'vtr_', got '{id}'"
1208 ));
1209 }
1210 }
1211 EVENT_KIND_TRAJECTORY_STEP_APPENDED => {
1212 require_str("proposal_id")?;
1213 let parent = require_str("parent_trajectory_id")?;
1214 if !parent.starts_with("vtr_") {
1215 return Err(format!(
1216 "parent_trajectory_id must start with 'vtr_', got '{parent}'"
1217 ));
1218 }
1219 let step = object
1220 .get("step")
1221 .and_then(Value::as_object)
1222 .ok_or("payload.step must be a JSON object")?;
1223 let kind_str = step.get("kind").and_then(Value::as_str).ok_or(
1224 "payload.step.kind must be one of hypothesis|tried|ruled_out|observed|refined",
1225 )?;
1226 if !matches!(
1227 kind_str,
1228 "hypothesis" | "tried" | "ruled_out" | "observed" | "refined"
1229 ) {
1230 return Err(format!(
1231 "payload.step.kind '{kind_str}' must be one of hypothesis|tried|ruled_out|observed|refined"
1232 ));
1233 }
1234 let description = step
1235 .get("description")
1236 .and_then(Value::as_str)
1237 .ok_or("payload.step.description must be a non-empty string")?;
1238 if description.trim().is_empty() {
1239 return Err("payload.step.description must be non-empty".to_string());
1240 }
1241 let actor = step
1242 .get("actor")
1243 .and_then(Value::as_str)
1244 .ok_or("payload.step.actor must be a non-empty string")?;
1245 if actor.trim().is_empty() {
1246 return Err("payload.step.actor must be non-empty".to_string());
1247 }
1248 }
1249 EVENT_KIND_TRAJECTORY_REVIEWED => {
1250 require_str("proposal_id")?;
1251 let status = require_str("status")?;
1252 if !matches!(
1253 status,
1254 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1255 ) {
1256 return Err(format!("invalid review status '{status}'"));
1257 }
1258 }
1259 EVENT_KIND_TRAJECTORY_RETRACTED => {
1260 require_str("proposal_id")?;
1261 }
1262 EVENT_KIND_ARTIFACT_ASSERTED => {
1263 require_str("proposal_id")?;
1264 let artifact = object
1265 .get("artifact")
1266 .and_then(Value::as_object)
1267 .ok_or("payload.artifact must be a JSON object")?;
1268 let id = artifact
1269 .get("id")
1270 .and_then(Value::as_str)
1271 .ok_or("payload.artifact.id must be a va_<hex>")?;
1272 if !id.starts_with("va_") {
1273 return Err(format!(
1274 "payload.artifact.id must start with 'va_', got '{id}'"
1275 ));
1276 }
1277 let id_hex = id.trim_start_matches("va_");
1278 if id_hex.len() != 16 || !id_hex.chars().all(|c| c.is_ascii_hexdigit()) {
1279 return Err("payload.artifact.id must be va_<16hex>".to_string());
1280 }
1281 let kind = artifact
1282 .get("kind")
1283 .and_then(Value::as_str)
1284 .ok_or("payload.artifact.kind must be a string")?;
1285 if !crate::bundle::valid_artifact_kind(kind) {
1286 return Err(format!("payload.artifact.kind '{kind}' is not supported"));
1287 }
1288 for key in ["name", "content_hash", "storage_mode"] {
1289 let value = artifact
1290 .get(key)
1291 .and_then(Value::as_str)
1292 .ok_or_else(|| format!("payload.artifact.{key} must be a string"))?;
1293 if value.trim().is_empty() {
1294 return Err(format!("payload.artifact.{key} must be non-empty"));
1295 }
1296 }
1297 let content_hash = artifact
1298 .get("content_hash")
1299 .and_then(Value::as_str)
1300 .expect("content_hash checked above");
1301 validate_sha256_commitment("payload.artifact.content_hash", content_hash)?;
1302 }
1303 EVENT_KIND_ARTIFACT_REVIEWED => {
1304 require_str("proposal_id")?;
1305 let status = require_str("status")?;
1306 if !matches!(
1307 status,
1308 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1309 ) {
1310 return Err(format!("invalid review status '{status}'"));
1311 }
1312 }
1313 EVENT_KIND_ARTIFACT_RETRACTED => {
1314 require_str("proposal_id")?;
1315 }
1316 EVENT_KIND_TIER_SET => {
1322 require_str("proposal_id")?;
1323 let object_type = require_str("object_type")?;
1324 if !matches!(
1325 object_type,
1326 "finding" | "negative_result" | "trajectory" | "artifact"
1327 ) {
1328 return Err(format!(
1329 "tier.set object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1330 ));
1331 }
1332 require_str("object_id")?;
1333 let new_tier = require_str("new_tier")?;
1334 crate::access_tier::AccessTier::parse(new_tier)?;
1335 if let Some(prev) = object.get("previous_tier").and_then(Value::as_str) {
1340 crate::access_tier::AccessTier::parse(prev)?;
1341 }
1342 }
1343 EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED => {
1349 require_str("proposal_id")?;
1350 let source_id = require_str("source_id")?;
1351 if source_id.trim().is_empty() {
1352 return Err("payload.source_id must be non-empty".to_string());
1353 }
1354 let locator = require_str("locator")?;
1355 if locator.trim().is_empty() {
1356 return Err("payload.locator must be non-empty".to_string());
1357 }
1358 }
1359 EVENT_KIND_FINDING_SPAN_REPAIRED => {
1362 require_str("proposal_id")?;
1363 let section = require_str("section")?;
1364 if section.trim().is_empty() {
1365 return Err("payload.section must be non-empty".to_string());
1366 }
1367 let text = require_str("text")?;
1368 if text.trim().is_empty() {
1369 return Err("payload.text must be non-empty".to_string());
1370 }
1371 }
1372 EVENT_KIND_FINDING_ENTITY_RESOLVED => {
1376 require_str("proposal_id")?;
1377 let entity_name = require_str("entity_name")?;
1378 if entity_name.trim().is_empty() {
1379 return Err("payload.entity_name must be non-empty".to_string());
1380 }
1381 let source = require_str("source")?;
1382 if source.trim().is_empty() {
1383 return Err("payload.source must be non-empty".to_string());
1384 }
1385 let id = require_str("id")?;
1386 if id.trim().is_empty() {
1387 return Err("payload.id must be non-empty".to_string());
1388 }
1389 let confidence = require_f64("confidence")?;
1390 if !(0.0..=1.0).contains(&confidence) {
1391 return Err(format!("payload.confidence {confidence} out of [0.0, 1.0]"));
1392 }
1393 }
1394 EVENT_KIND_ATTESTATION_RECORDED => {
1398 let target_id = require_str("target_event_id")?;
1399 if !target_id.starts_with("vev_") {
1400 return Err(format!(
1401 "payload.target_event_id must start with 'vev_', got '{target_id}'"
1402 ));
1403 }
1404 let attester = require_str("attester_id")?;
1405 if attester.trim().is_empty() {
1406 return Err("payload.attester_id must be non-empty".to_string());
1407 }
1408 let scope = require_str("scope_note")?;
1409 if scope.trim().is_empty() {
1410 return Err("payload.scope_note must be non-empty".to_string());
1411 }
1412 if let Some(sig) = object.get("signature")
1414 && !sig.is_null()
1415 && !sig.is_string()
1416 {
1417 return Err("payload.signature must be a string when present".to_string());
1418 }
1419 if let Some(proof) = object.get("proof_id")
1420 && !proof.is_null()
1421 && let Some(s) = proof.as_str()
1422 && !s.starts_with("vpf_")
1423 {
1424 return Err(format!(
1425 "payload.proof_id must start with 'vpf_' when present, got '{s}'"
1426 ));
1427 }
1428 }
1429 EVENT_KIND_FINDING_ENTITY_ADDED => {
1436 require_str("proposal_id")?;
1437 let entity_name = require_str("entity_name")?;
1438 if entity_name.trim().is_empty() {
1439 return Err("payload.entity_name must be non-empty".to_string());
1440 }
1441 let entity_type = require_str("entity_type")?;
1442 const VALID_ENTITY_TYPES: &[&str] = &[
1443 "gene",
1444 "protein",
1445 "compound",
1446 "disease",
1447 "cell_type",
1448 "organism",
1449 "pathway",
1450 "assay",
1451 "anatomical_structure",
1452 "particle",
1453 "instrument",
1454 "dataset",
1455 "quantity",
1456 "other",
1457 ];
1458 if !VALID_ENTITY_TYPES.contains(&entity_type) {
1459 return Err(format!(
1460 "payload.entity_type '{entity_type}' not in {VALID_ENTITY_TYPES:?}"
1461 ));
1462 }
1463 let reason = require_str("reason")?;
1464 if reason.trim().is_empty() {
1465 return Err("payload.reason must be non-empty".to_string());
1466 }
1467 }
1468 other => return Err(format!("unknown event kind '{other}'")),
1469 }
1470 Ok(())
1471}
1472
1473pub fn validate_bridge_reviewed_against_state(
1491 payload: &Value,
1492 known_bridge_ids: &[String],
1493) -> Result<(), String> {
1494 let object = payload
1495 .as_object()
1496 .ok_or_else(|| "payload must be a JSON object".to_string())?;
1497 let bridge_id = object
1498 .get("bridge_id")
1499 .and_then(Value::as_str)
1500 .ok_or_else(|| "missing required string field 'bridge_id'".to_string())?;
1501 if !known_bridge_ids.iter().any(|id| id == bridge_id) {
1502 return Err(format!(
1503 "bridge_id '{bridge_id}' not present on this frontier (no matching .vela/bridges/<id>.json)"
1504 ));
1505 }
1506 Ok(())
1507}
1508
1509pub fn compute_event_id(event: &StateEvent) -> String {
1514 event_id(event)
1515}
1516
1517fn event_id(event: &StateEvent) -> String {
1518 let content = json!({
1519 "schema": event.schema,
1520 "kind": event.kind,
1521 "target": event.target,
1522 "actor": event.actor,
1523 "timestamp": event.timestamp,
1524 "reason": event.reason,
1525 "before_hash": event.before_hash,
1526 "after_hash": event.after_hash,
1527 "payload": event.payload,
1528 "caveats": event.caveats,
1529 });
1530 let bytes = canonical::to_canonical_bytes(&content).unwrap_or_default();
1531 format!("vev_{}", &hex::encode(Sha256::digest(bytes))[..16])
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use crate::bundle::{
1538 Assertion, Conditions, Confidence, Evidence, Extraction, FindingBundle, Flags, Provenance,
1539 };
1540 use crate::project;
1541
1542 fn finding() -> FindingBundle {
1543 FindingBundle::new(
1544 Assertion {
1545 text: "LRP1 clears amyloid beta at the BBB".to_string(),
1546 assertion_type: "mechanism".to_string(),
1547 entities: Vec::new(),
1548 relation: None,
1549 direction: None,
1550 causal_claim: None,
1551 causal_evidence_grade: None,
1552 },
1553 Evidence {
1554 evidence_type: "experimental".to_string(),
1555 model_system: "mouse".to_string(),
1556 species: Some("Mus musculus".to_string()),
1557 method: "assay".to_string(),
1558 sample_size: None,
1559 effect_size: None,
1560 p_value: None,
1561 replicated: false,
1562 replication_count: None,
1563 evidence_spans: Vec::new(),
1564 },
1565 Conditions {
1566 text: "mouse model".to_string(),
1567 species_verified: Vec::new(),
1568 species_unverified: Vec::new(),
1569 in_vitro: false,
1570 in_vivo: true,
1571 human_data: false,
1572 clinical_trial: false,
1573 concentration_range: None,
1574 duration: None,
1575 age_group: None,
1576 cell_type: None,
1577 },
1578 Confidence::raw(0.6, "test", 0.8),
1579 Provenance {
1580 source_type: "published_paper".to_string(),
1581 doi: None,
1582 pmid: None,
1583 pmc: None,
1584 openalex_id: None,
1585 url: None,
1586 title: "Test source".to_string(),
1587 authors: Vec::new(),
1588 year: Some(2026),
1589 journal: None,
1590 license: None,
1591 publisher: None,
1592 funders: Vec::new(),
1593 extraction: Extraction::default(),
1594 review: None,
1595 citation_count: None,
1596 },
1597 Flags {
1598 gap: false,
1599 negative_space: false,
1600 contested: false,
1601 retracted: false,
1602 declining: false,
1603 gravity_well: false,
1604 review_state: None,
1605 superseded: false,
1606 signature_threshold: None,
1607 jointly_accepted: false,
1608 },
1609 )
1610 }
1611
1612 #[test]
1613 fn event_id_is_deterministic_for_content() {
1614 let event = new_finding_event(FindingEventInput {
1615 kind: "finding.reviewed",
1616 finding_id: "vf_test",
1617 actor_id: "reviewer",
1618 actor_type: "human",
1619 reason: "checked",
1620 before_hash: NULL_HASH,
1621 after_hash: "sha256:abc",
1622 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1623 caveats: vec![],
1624 });
1625 let mut same = event.clone();
1626 same.id = String::new();
1627 same.id = super::event_id(&same);
1628 assert_eq!(event.id, same.id);
1629 }
1630
1631 #[test]
1632 fn genesis_only_event_log_replays_ok() {
1633 let frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1638 let report = replay_report(&frontier);
1639 assert!(report.ok, "{:?}", report.conflicts);
1640 assert_eq!(report.event_log.count, 1);
1641 assert_eq!(report.event_log.kinds.get("frontier.created"), Some(&1));
1642 }
1643
1644 #[test]
1645 fn replay_detects_duplicate_event_ids() {
1646 let finding = finding();
1647 let after_hash = finding_hash(&finding);
1648 let event = new_finding_event(FindingEventInput {
1649 kind: "finding.reviewed",
1650 finding_id: &finding.id,
1651 actor_id: "reviewer",
1652 actor_type: "human",
1653 reason: "checked",
1654 before_hash: &after_hash,
1655 after_hash: &after_hash,
1656 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1657 caveats: vec![],
1658 });
1659 let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1660 frontier.events = vec![event.clone(), event];
1661
1662 let report = replay_report(&frontier);
1663 assert!(!report.ok);
1664 assert_eq!(report.status, "conflict");
1665 assert!(!report.event_log.duplicate_ids.is_empty());
1666 }
1667
1668 #[test]
1669 fn replay_detects_orphan_targets() {
1670 let mut frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1671 frontier.events.push(new_finding_event(FindingEventInput {
1672 kind: "finding.reviewed",
1673 finding_id: "vf_missing",
1674 actor_id: "reviewer",
1675 actor_type: "human",
1676 reason: "checked",
1677 before_hash: NULL_HASH,
1678 after_hash: "sha256:abc",
1679 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1680 caveats: vec![],
1681 }));
1682
1683 let report = replay_report(&frontier);
1684 assert!(!report.ok);
1685 assert_eq!(report.event_log.orphan_targets, vec!["vf_missing"]);
1686 }
1687
1688 #[test]
1689 fn replay_accepts_current_hash_boundary() {
1690 let finding = finding();
1691 let hash = finding_hash(&finding);
1692 let event = new_finding_event(FindingEventInput {
1693 kind: "finding.reviewed",
1694 finding_id: &finding.id,
1695 actor_id: "reviewer",
1696 actor_type: "human",
1697 reason: "checked",
1698 before_hash: &hash,
1699 after_hash: &hash,
1700 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1701 caveats: vec![],
1702 });
1703 let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1704 frontier.events.push(event);
1705
1706 let report = replay_report(&frontier);
1707 assert!(report.ok, "{:?}", report.conflicts);
1708 assert_eq!(report.status, "ok");
1709 }
1710
1711 #[test]
1713 fn validates_synced_with_peer_payload() {
1714 assert!(
1716 validate_event_payload(
1717 "frontier.synced_with_peer",
1718 &json!({
1719 "peer_id": "hub:peer",
1720 "peer_snapshot_hash": "abc",
1721 "our_snapshot_hash": "def",
1722 "divergence_count": 3,
1723 }),
1724 )
1725 .is_ok()
1726 );
1727 assert!(
1729 validate_event_payload(
1730 "frontier.synced_with_peer",
1731 &json!({
1732 "peer_id": "hub:peer",
1733 "peer_snapshot_hash": "abc",
1734 "our_snapshot_hash": "def",
1735 }),
1736 )
1737 .is_err()
1738 );
1739 assert!(
1741 validate_event_payload(
1742 "frontier.synced_with_peer",
1743 &json!({
1744 "peer_snapshot_hash": "abc",
1745 "our_snapshot_hash": "def",
1746 "divergence_count": 0,
1747 }),
1748 )
1749 .is_err()
1750 );
1751 }
1752
1753 #[test]
1754 fn validates_conflict_detected_payload() {
1755 assert!(
1757 validate_event_payload(
1758 "frontier.conflict_detected",
1759 &json!({
1760 "peer_id": "hub:peer",
1761 "finding_id": "vf_xyz",
1762 "kind": "different_review_verdict",
1763 }),
1764 )
1765 .is_ok()
1766 );
1767 assert!(
1769 validate_event_payload(
1770 "frontier.conflict_detected",
1771 &json!({
1772 "peer_id": "hub:peer",
1773 "finding_id": "vf_xyz",
1774 "kind": " ",
1775 }),
1776 )
1777 .is_err()
1778 );
1779 assert!(
1781 validate_event_payload(
1782 "frontier.conflict_detected",
1783 &json!({
1784 "peer_id": "hub:peer",
1785 "kind": "missing_in_peer",
1786 }),
1787 )
1788 .is_err()
1789 );
1790 }
1791
1792 #[test]
1793 fn validates_artifact_asserted_payload() {
1794 let good_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1795 assert!(
1796 validate_event_payload(
1797 EVENT_KIND_ARTIFACT_ASSERTED,
1798 &json!({
1799 "proposal_id": "vpr_test",
1800 "artifact": {
1801 "id": "va_1234567890abcdef",
1802 "kind": "clinical_trial_record",
1803 "name": "NCT test record",
1804 "content_hash": good_hash,
1805 "storage_mode": "embedded",
1806 },
1807 }),
1808 )
1809 .is_ok()
1810 );
1811 assert!(
1812 validate_event_payload(
1813 EVENT_KIND_ARTIFACT_ASSERTED,
1814 &json!({
1815 "proposal_id": "vpr_test",
1816 "artifact": {
1817 "id": "va_123",
1818 "kind": "clinical_trial_record",
1819 "name": "NCT test record",
1820 "content_hash": good_hash,
1821 "storage_mode": "embedded",
1822 },
1823 }),
1824 )
1825 .is_err()
1826 );
1827 assert!(
1828 validate_event_payload(
1829 EVENT_KIND_ARTIFACT_ASSERTED,
1830 &json!({
1831 "proposal_id": "vpr_test",
1832 "artifact": {
1833 "id": "va_1234567890abcdef",
1834 "kind": "clinical_trial_record",
1835 "name": "NCT test record",
1836 "content_hash": "sha256:not-a-real-hash",
1837 "storage_mode": "embedded",
1838 },
1839 }),
1840 )
1841 .is_err()
1842 );
1843 }
1844
1845 #[test]
1847 fn validates_reinterpreted_causal_payload() {
1848 assert!(
1850 validate_event_payload(
1851 "assertion.reinterpreted_causal",
1852 &json!({
1853 "proposal_id": "vpr_test",
1854 "before": {},
1855 "after": { "claim": "intervention", "grade": "rct" },
1856 }),
1857 )
1858 .is_ok()
1859 );
1860 assert!(
1862 validate_event_payload(
1863 "assertion.reinterpreted_causal",
1864 &json!({
1865 "proposal_id": "vpr_test",
1866 "before": { "claim": "correlation" },
1867 "after": { "claim": "mediation" },
1868 }),
1869 )
1870 .is_ok()
1871 );
1872 assert!(
1874 validate_event_payload(
1875 "assertion.reinterpreted_causal",
1876 &json!({
1877 "proposal_id": "vpr_test",
1878 "before": {},
1879 "after": { "claim": "magic" },
1880 }),
1881 )
1882 .is_err()
1883 );
1884 assert!(
1886 validate_event_payload(
1887 "assertion.reinterpreted_causal",
1888 &json!({
1889 "proposal_id": "vpr_test",
1890 "before": {},
1891 "after": { "claim": "intervention", "grade": "vibes" },
1892 }),
1893 )
1894 .is_err()
1895 );
1896 assert!(
1898 validate_event_payload(
1899 "assertion.reinterpreted_causal",
1900 &json!({
1901 "before": {},
1902 "after": { "claim": "intervention" },
1903 }),
1904 )
1905 .is_err()
1906 );
1907 }
1908
1909 #[test]
1914 fn revocation_event_canonical_shape() {
1915 use crate::canonical;
1916 let payload = RevocationPayload {
1917 revoked_pubkey: "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e"
1918 .to_string(),
1919 revoked_at: "2026-05-01T17:00:00Z".to_string(),
1920 replacement_pubkey: "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9"
1921 .to_string(),
1922 reason: "key file leaked from CI cache".to_string(),
1923 };
1924 let event = new_revocation_event(
1925 "reviewer:will-blair",
1926 "human",
1927 payload,
1928 "rotating compromised key",
1929 NULL_HASH,
1930 NULL_HASH,
1931 );
1932 assert_eq!(event.kind, EVENT_KIND_KEY_REVOKE);
1933 assert_eq!(event.target.r#type, "actor");
1934 assert!(event.id.starts_with("vev_"));
1935 let bytes = canonical::to_canonical_bytes(&event).unwrap();
1936 let s = std::str::from_utf8(&bytes).unwrap();
1937 assert!(
1938 s.contains("\"revoked_pubkey\""),
1939 "canonical bytes missing revoked_pubkey: {s}"
1940 );
1941 assert!(
1942 s.contains("\"revoked_at\""),
1943 "canonical bytes missing revoked_at: {s}"
1944 );
1945 assert!(
1946 s.contains("\"replacement_pubkey\""),
1947 "canonical bytes missing replacement_pubkey: {s}"
1948 );
1949
1950 let payload_minimal = RevocationPayload {
1952 revoked_pubkey: "a".repeat(64),
1953 revoked_at: "2026-05-01T17:00:00Z".to_string(),
1954 replacement_pubkey: String::new(),
1955 reason: String::new(),
1956 };
1957 let minimal_event = new_revocation_event(
1958 "reviewer:will-blair",
1959 "human",
1960 payload_minimal,
1961 "scheduled rotation",
1962 NULL_HASH,
1963 NULL_HASH,
1964 );
1965 let minimal_bytes = canonical::to_canonical_bytes(&minimal_event).unwrap();
1966 let minimal_s = std::str::from_utf8(&minimal_bytes).unwrap();
1967 assert!(
1968 !minimal_s.contains("\"replacement_pubkey\""),
1969 "empty replacement_pubkey leaked into canonical JSON: {minimal_s}"
1970 );
1971 assert!(
1972 !minimal_s.contains("\"reason\":\"\""),
1973 "empty payload reason leaked into canonical JSON: {minimal_s}"
1974 );
1975 }
1976
1977 #[test]
1980 fn revocation_payload_validation() {
1981 let good_pubkey = "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e";
1982 let other_pubkey = "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9";
1983
1984 assert!(
1986 validate_event_payload(
1987 EVENT_KIND_KEY_REVOKE,
1988 &json!({
1989 "revoked_pubkey": good_pubkey,
1990 "revoked_at": "2026-05-01T17:00:00Z",
1991 }),
1992 )
1993 .is_ok()
1994 );
1995
1996 assert!(
1998 validate_event_payload(
1999 EVENT_KIND_KEY_REVOKE,
2000 &json!({
2001 "revoked_pubkey": good_pubkey,
2002 "revoked_at": "2026-05-01T17:00:00Z",
2003 "replacement_pubkey": other_pubkey,
2004 "reason": "key file leaked",
2005 }),
2006 )
2007 .is_ok()
2008 );
2009
2010 assert!(
2012 validate_event_payload(
2013 EVENT_KIND_KEY_REVOKE,
2014 &json!({
2015 "revoked_pubkey": "abc123",
2016 "revoked_at": "2026-05-01T17:00:00Z",
2017 }),
2018 )
2019 .is_err()
2020 );
2021
2022 assert!(
2024 validate_event_payload(
2025 EVENT_KIND_KEY_REVOKE,
2026 &json!({
2027 "revoked_pubkey": "ZZ".repeat(32),
2028 "revoked_at": "2026-05-01T17:00:00Z",
2029 }),
2030 )
2031 .is_err()
2032 );
2033
2034 assert!(
2036 validate_event_payload(
2037 EVENT_KIND_KEY_REVOKE,
2038 &json!({
2039 "revoked_pubkey": good_pubkey,
2040 }),
2041 )
2042 .is_err()
2043 );
2044
2045 assert!(
2047 validate_event_payload(
2048 EVENT_KIND_KEY_REVOKE,
2049 &json!({
2050 "revoked_pubkey": good_pubkey,
2051 "revoked_at": "2026-05-01T17:00:00Z",
2052 "replacement_pubkey": "deadbeef",
2053 }),
2054 )
2055 .is_err()
2056 );
2057
2058 assert!(
2060 validate_event_payload(
2061 EVENT_KIND_KEY_REVOKE,
2062 &json!({
2063 "revoked_pubkey": good_pubkey,
2064 "revoked_at": "2026-05-01T17:00:00Z",
2065 "replacement_pubkey": good_pubkey,
2066 }),
2067 )
2068 .is_err()
2069 );
2070
2071 for bad in [
2079 "yesterday",
2080 "2026-13-01T00:00:00Z", "2026-05-01", "x",
2083 ] {
2084 assert!(
2085 validate_event_payload(
2086 EVENT_KIND_KEY_REVOKE,
2087 &json!({
2088 "revoked_pubkey": good_pubkey,
2089 "revoked_at": bad,
2090 }),
2091 )
2092 .is_err(),
2093 "expected revoked_at {bad:?} to fail validation"
2094 );
2095 }
2096 }
2097
2098 #[test]
2103 fn attestation_recorded_validator() {
2104 let good = json!({
2106 "target_event_id": "vev_abc",
2107 "attester_id": "reviewer:will-blair",
2108 "scope_note": "Independent re-verification of the Stupp protocol finding."
2109 });
2110 assert!(validate_event_payload(EVENT_KIND_ATTESTATION_RECORDED, &good).is_ok());
2111
2112 let with_proof = json!({
2114 "target_event_id": "vev_abc",
2115 "attester_id": "reviewer:will-blair",
2116 "scope_note": "Lean-formalized.",
2117 "signature": "ed25519:cafebabe",
2118 "proof_id": "vpf_demo"
2119 });
2120 assert!(validate_event_payload(EVENT_KIND_ATTESTATION_RECORDED, &with_proof).is_ok());
2121
2122 let bad_target = json!({
2124 "target_event_id": "something_else",
2125 "attester_id": "reviewer:x",
2126 "scope_note": "x"
2127 });
2128 assert!(validate_event_payload(EVENT_KIND_ATTESTATION_RECORDED, &bad_target).is_err());
2129
2130 let no_attester = json!({
2132 "target_event_id": "vev_abc",
2133 "attester_id": "",
2134 "scope_note": "x"
2135 });
2136 assert!(validate_event_payload(EVENT_KIND_ATTESTATION_RECORDED, &no_attester).is_err());
2137
2138 let bad_proof = json!({
2140 "target_event_id": "vev_abc",
2141 "attester_id": "reviewer:x",
2142 "scope_note": "x",
2143 "proof_id": "not_a_vpf"
2144 });
2145 assert!(validate_event_payload(EVENT_KIND_ATTESTATION_RECORDED, &bad_proof).is_err());
2146 }
2147
2148 #[test]
2152 fn finding_entity_added_validator() {
2153 let good = json!({
2155 "proposal_id": "vpr_demo",
2156 "entity_name": "claudin-5",
2157 "entity_type": "protein",
2158 "reason": "Cardinal BBB tight-junction protein; cited in finding source paper."
2159 });
2160 assert!(validate_event_payload(EVENT_KIND_FINDING_ENTITY_ADDED, &good).is_ok());
2161
2162 let no_reason = json!({
2164 "proposal_id": "vpr_demo",
2165 "entity_name": "claudin-5",
2166 "entity_type": "protein"
2167 });
2168 assert!(validate_event_payload(EVENT_KIND_FINDING_ENTITY_ADDED, &no_reason).is_err());
2169
2170 let bad_type = json!({
2172 "proposal_id": "vpr_demo",
2173 "entity_name": "claudin-5",
2174 "entity_type": "fancy_new_thing",
2175 "reason": "x"
2176 });
2177 assert!(validate_event_payload(EVENT_KIND_FINDING_ENTITY_ADDED, &bad_type).is_err());
2178
2179 let empty_name = json!({
2181 "proposal_id": "vpr_demo",
2182 "entity_name": "",
2183 "entity_type": "protein",
2184 "reason": "x"
2185 });
2186 assert!(validate_event_payload(EVENT_KIND_FINDING_ENTITY_ADDED, &empty_name).is_err());
2187 }
2188
2189 #[test]
2194 fn bridge_reviewed_state_aware_rejects_unknown_id() {
2195 let known: Vec<String> = vec!["vbr_aaaaaaaaaaaaaaaa".to_string()];
2196
2197 assert!(
2199 validate_bridge_reviewed_against_state(
2200 &json!({
2201 "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
2202 "status": "confirmed",
2203 }),
2204 &known,
2205 )
2206 .is_ok()
2207 );
2208
2209 let err = validate_bridge_reviewed_against_state(
2211 &json!({
2212 "bridge_id": "vbr_bbbbbbbbbbbbbbbb",
2213 "status": "confirmed",
2214 }),
2215 &known,
2216 )
2217 .expect_err("expected unknown bridge_id to be rejected");
2218 assert!(
2219 err.contains("not present on this frontier"),
2220 "error should explain the gap: {err}"
2221 );
2222
2223 assert!(
2227 validate_bridge_reviewed_against_state(
2228 &json!({
2229 "status": "confirmed",
2230 }),
2231 &known,
2232 )
2233 .is_err()
2234 );
2235
2236 assert!(
2239 validate_bridge_reviewed_against_state(
2240 &json!({
2241 "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
2242 "status": "confirmed",
2243 }),
2244 &[],
2245 )
2246 .is_err()
2247 );
2248 }
2249}