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_REPLICATION_DEPOSITED: &str = "replication.deposited";
111
112pub const EVENT_KIND_PREDICTION_DEPOSITED: &str = "prediction.deposited";
117
118pub const EVENT_KIND_BRIDGE_REVIEWED: &str = "bridge.reviewed";
126
127pub const EVENT_KIND_FRONTIER_CONFLICT_RESOLVED: &str = "frontier.conflict_resolved";
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct StateTarget {
147 pub r#type: String,
148 pub id: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct StateActor {
153 pub id: String,
154 pub r#type: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct StateEvent {
159 #[serde(default = "default_schema")]
160 pub schema: String,
161 pub id: String,
162 pub kind: String,
163 pub target: StateTarget,
164 pub actor: StateActor,
165 pub timestamp: String,
166 pub reason: String,
167 pub before_hash: String,
168 pub after_hash: String,
169 #[serde(default)]
170 pub payload: Value,
171 #[serde(default)]
172 pub caveats: Vec<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub signature: Option<String>,
175}
176
177pub struct FindingEventInput<'a> {
178 pub kind: &'a str,
179 pub finding_id: &'a str,
180 pub actor_id: &'a str,
181 pub actor_type: &'a str,
182 pub reason: &'a str,
183 pub before_hash: &'a str,
184 pub after_hash: &'a str,
185 pub payload: Value,
186 pub caveats: Vec<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct EventLogSummary {
191 pub count: usize,
192 pub kinds: BTreeMap<String, usize>,
193 pub first_timestamp: Option<String>,
194 pub last_timestamp: Option<String>,
195 pub duplicate_ids: Vec<String>,
196 pub orphan_targets: Vec<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ReplayReport {
201 pub ok: bool,
202 pub status: String,
203 pub event_log: EventLogSummary,
204 pub source_hash: String,
205 pub event_log_hash: String,
206 pub replayed_hash: String,
207 pub current_hash: String,
208 pub conflicts: Vec<String>,
209}
210
211fn default_schema() -> String {
212 EVENT_SCHEMA.to_string()
213}
214
215pub fn new_finding_event(input: FindingEventInput<'_>) -> StateEvent {
216 let timestamp = Utc::now().to_rfc3339();
217 let mut event = StateEvent {
218 schema: EVENT_SCHEMA.to_string(),
219 id: String::new(),
220 kind: input.kind.to_string(),
221 target: StateTarget {
222 r#type: "finding".to_string(),
223 id: input.finding_id.to_string(),
224 },
225 actor: StateActor {
226 id: input.actor_id.to_string(),
227 r#type: input.actor_type.to_string(),
228 },
229 timestamp,
230 reason: input.reason.to_string(),
231 before_hash: input.before_hash.to_string(),
232 after_hash: input.after_hash.to_string(),
233 payload: input.payload,
234 caveats: input.caveats,
235 signature: None,
236 };
237 event.id = event_id(&event);
238 event
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250pub struct RevocationPayload {
251 pub revoked_pubkey: String,
253 pub revoked_at: String,
257 #[serde(default, skip_serializing_if = "String::is_empty")]
261 pub replacement_pubkey: String,
262 #[serde(default, skip_serializing_if = "String::is_empty")]
266 pub reason: String,
267}
268
269pub fn new_revocation_event(
275 actor_id: &str,
276 actor_type: &str,
277 payload: RevocationPayload,
278 reason: &str,
279 before_hash: &str,
280 after_hash: &str,
281) -> StateEvent {
282 let timestamp = Utc::now().to_rfc3339();
283 let payload_value =
284 serde_json::to_value(&payload).expect("RevocationPayload serializes to a JSON object");
285 let mut event = StateEvent {
286 schema: EVENT_SCHEMA.to_string(),
287 id: String::new(),
288 kind: EVENT_KIND_KEY_REVOKE.to_string(),
289 target: StateTarget {
290 r#type: "actor".to_string(),
291 id: actor_id.to_string(),
292 },
293 actor: StateActor {
294 id: actor_id.to_string(),
295 r#type: actor_type.to_string(),
296 },
297 timestamp,
298 reason: reason.to_string(),
299 before_hash: before_hash.to_string(),
300 after_hash: after_hash.to_string(),
301 payload: payload_value,
302 caveats: Vec::new(),
303 signature: None,
304 };
305 event.id = event_id(&event);
306 event
307}
308
309pub fn new_evidence_atom_locator_repair_event(
315 atom_id: &str,
316 actor_id: &str,
317 actor_type: &str,
318 reason: &str,
319 before_hash: &str,
320 after_hash: &str,
321 payload: Value,
322 caveats: Vec<String>,
323) -> StateEvent {
324 let timestamp = Utc::now().to_rfc3339();
325 let mut event = StateEvent {
326 schema: EVENT_SCHEMA.to_string(),
327 id: String::new(),
328 kind: EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED.to_string(),
329 target: StateTarget {
330 r#type: "evidence_atom".to_string(),
331 id: atom_id.to_string(),
332 },
333 actor: StateActor {
334 id: actor_id.to_string(),
335 r#type: actor_type.to_string(),
336 },
337 timestamp,
338 reason: reason.to_string(),
339 before_hash: before_hash.to_string(),
340 after_hash: after_hash.to_string(),
341 payload,
342 caveats,
343 signature: None,
344 };
345 event.id = event_id(&event);
346 event
347}
348
349pub fn new_frontier_conflict_resolved_event(
354 frontier_id: &str,
355 actor_id: &str,
356 actor_type: &str,
357 reason: &str,
358 payload: Value,
359 caveats: Vec<String>,
360) -> StateEvent {
361 let timestamp = Utc::now().to_rfc3339();
362 let mut event = StateEvent {
363 schema: EVENT_SCHEMA.to_string(),
364 id: String::new(),
365 kind: EVENT_KIND_FRONTIER_CONFLICT_RESOLVED.to_string(),
366 target: StateTarget {
367 r#type: "frontier_observation".to_string(),
368 id: frontier_id.to_string(),
369 },
370 actor: StateActor {
371 id: actor_id.to_string(),
372 r#type: actor_type.to_string(),
373 },
374 timestamp,
375 reason: reason.to_string(),
376 before_hash: NULL_HASH.to_string(),
377 after_hash: NULL_HASH.to_string(),
378 payload,
379 caveats,
380 signature: None,
381 };
382 event.id = event_id(&event);
383 event
384}
385
386pub fn new_bridge_reviewed_event(
390 bridge_id: &str,
391 actor_id: &str,
392 actor_type: &str,
393 reason: &str,
394 payload: Value,
395 caveats: Vec<String>,
396) -> StateEvent {
397 let timestamp = Utc::now().to_rfc3339();
398 let mut event = StateEvent {
399 schema: EVENT_SCHEMA.to_string(),
400 id: String::new(),
401 kind: EVENT_KIND_BRIDGE_REVIEWED.to_string(),
402 target: StateTarget {
403 r#type: "bridge".to_string(),
404 id: bridge_id.to_string(),
405 },
406 actor: StateActor {
407 id: actor_id.to_string(),
408 r#type: actor_type.to_string(),
409 },
410 timestamp,
411 reason: reason.to_string(),
412 before_hash: NULL_HASH.to_string(),
413 after_hash: NULL_HASH.to_string(),
414 payload,
415 caveats,
416 signature: None,
417 };
418 event.id = event_id(&event);
419 event
420}
421
422pub fn evidence_atom_hash(atom: &crate::sources::EvidenceAtom) -> String {
427 let bytes = canonical::to_canonical_bytes(atom).unwrap_or_default();
428 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
429}
430
431pub fn evidence_atom_hash_by_id(frontier: &Project, atom_id: &str) -> String {
432 frontier
433 .evidence_atoms
434 .iter()
435 .find(|atom| atom.id == atom_id)
436 .map(evidence_atom_hash)
437 .unwrap_or_else(|| NULL_HASH.to_string())
438}
439
440pub fn finding_hash(finding: &FindingBundle) -> String {
441 let mut hashable = finding.clone();
452 hashable.links.clear();
453 let bytes = canonical::to_canonical_bytes(&hashable).unwrap_or_default();
454 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
455}
456
457pub fn finding_hash_by_id(frontier: &Project, finding_id: &str) -> String {
458 frontier
459 .findings
460 .iter()
461 .find(|finding| finding.id == finding_id)
462 .map(finding_hash)
463 .unwrap_or_else(|| NULL_HASH.to_string())
464}
465
466pub fn event_log_hash(events: &[StateEvent]) -> String {
467 let bytes = canonical::to_canonical_bytes(events).unwrap_or_default();
468 hex::encode(Sha256::digest(bytes))
469}
470
471pub fn snapshot_hash(frontier: &Project) -> String {
472 let value = serde_json::to_value(frontier).unwrap_or(Value::Null);
473 let mut value = value;
474 if let Value::Object(map) = &mut value {
475 map.remove("events");
476 map.remove("signatures");
477 map.remove("proof_state");
478 }
479 let bytes = canonical::to_canonical_bytes(&value).unwrap_or_default();
480 hex::encode(Sha256::digest(bytes))
481}
482
483pub fn events_for_finding<'a>(frontier: &'a Project, finding_id: &str) -> Vec<&'a StateEvent> {
484 frontier
485 .events
486 .iter()
487 .filter(|event| event.target.r#type == "finding" && event.target.id == finding_id)
488 .collect()
489}
490
491pub fn replay_report(frontier: &Project) -> ReplayReport {
492 let event_log = summarize(frontier);
493 let mut conflicts = Vec::new();
494
495 if frontier.events.is_empty() {
496 let current_hash = snapshot_hash(frontier);
497 return ReplayReport {
498 ok: true,
499 status: "no_events".to_string(),
500 event_log,
501 source_hash: current_hash.clone(),
502 event_log_hash: event_log_hash(&frontier.events),
503 replayed_hash: current_hash.clone(),
504 current_hash,
505 conflicts,
506 };
507 }
508
509 for duplicate in &event_log.duplicate_ids {
510 conflicts.push(format!("duplicate event id: {duplicate}"));
511 }
512 for orphan in &event_log.orphan_targets {
513 conflicts.push(format!("orphan event target: {orphan}"));
514 }
515
516 let mut chains = BTreeMap::<String, Vec<&StateEvent>>::new();
517 for event in &frontier.events {
518 if event.schema != EVENT_SCHEMA {
519 conflicts.push(format!(
520 "unsupported event schema for {}: {}",
521 event.id, event.schema
522 ));
523 }
524 if event.reason.trim().is_empty() {
525 conflicts.push(format!("event {} has empty reason", event.id));
526 }
527 if event.before_hash.trim().is_empty() || event.after_hash.trim().is_empty() {
528 conflicts.push(format!("event {} has empty hash boundary", event.id));
529 }
530 if let Err(err) = validate_event_payload(&event.kind, &event.payload) {
535 conflicts.push(format!("event {} payload invalid: {err}", event.id));
536 }
537 chains
538 .entry(format!("{}:{}", event.target.r#type, event.target.id))
539 .or_default()
540 .push(event);
541 }
542
543 for (target, events) in chains {
544 let mut sorted = events;
545 sorted.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)));
546 for pair in sorted.windows(2) {
547 let previous = pair[0];
548 let next = pair[1];
549 if previous.after_hash != next.before_hash {
550 conflicts.push(format!(
551 "event chain break for {target}: {} after_hash does not match {} before_hash",
552 previous.id, next.id
553 ));
554 }
555 }
556 if let Some(last) = sorted.last()
557 && last.target.r#type == "finding"
558 {
559 let current = finding_hash_by_id(frontier, &last.target.id);
560 if current != last.after_hash {
561 conflicts.push(format!(
562 "materialized finding {} hash does not match last event {}",
563 last.target.id, last.id
564 ));
565 }
566 }
567 }
568
569 let current_hash = snapshot_hash(frontier);
570 let ok = conflicts.is_empty();
571 ReplayReport {
572 ok,
573 status: if ok { "ok" } else { "conflict" }.to_string(),
574 event_log,
575 source_hash: current_hash.clone(),
576 event_log_hash: event_log_hash(&frontier.events),
577 replayed_hash: if ok {
578 current_hash.clone()
579 } else {
580 "unavailable".to_string()
581 },
582 current_hash,
583 conflicts,
584 }
585}
586
587pub fn replay_report_json(frontier: &Project) -> Value {
588 serde_json::to_value(replay_report(frontier)).unwrap_or_else(|_| json!({"ok": false}))
589}
590
591pub fn summarize(frontier: &Project) -> EventLogSummary {
592 let mut kinds = BTreeMap::<String, usize>::new();
593 let mut seen = BTreeSet::<String>::new();
594 let mut duplicate_ids = BTreeSet::<String>::new();
595 let finding_ids = frontier
596 .findings
597 .iter()
598 .map(|finding| finding.id.as_str())
599 .collect::<BTreeSet<_>>();
600 let mut orphan_targets = BTreeSet::<String>::new();
601 let mut timestamps = Vec::<String>::new();
602
603 for event in &frontier.events {
604 *kinds.entry(event.kind.clone()).or_default() += 1;
605 if !seen.insert(event.id.clone()) {
606 duplicate_ids.insert(event.id.clone());
607 }
608 if event.target.r#type == "finding"
609 && !finding_ids.contains(event.target.id.as_str())
610 && event.kind != "finding.retracted"
611 {
612 orphan_targets.insert(event.target.id.clone());
613 }
614 timestamps.push(event.timestamp.clone());
615 }
616 timestamps.sort();
617
618 EventLogSummary {
619 count: frontier.events.len(),
620 kinds,
621 first_timestamp: timestamps.first().cloned(),
622 last_timestamp: timestamps.last().cloned(),
623 duplicate_ids: duplicate_ids.into_iter().collect(),
624 orphan_targets: orphan_targets.into_iter().collect(),
625 }
626}
627
628fn validate_sha256_commitment(field: &str, value: &str) -> Result<(), String> {
639 let hex = value.strip_prefix("sha256:").unwrap_or(value);
640 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
641 return Err(format!("{field} must be sha256:<64hex>"));
642 }
643 Ok(())
644}
645
646pub fn validate_event_payload(kind: &str, payload: &Value) -> Result<(), String> {
647 let object = payload.as_object().ok_or_else(|| {
648 if matches!(payload, Value::Null) {
649 "payload must be a JSON object (got null)".to_string()
650 } else {
651 "payload must be a JSON object".to_string()
652 }
653 })?;
654 let require_str = |key: &str| -> Result<&str, String> {
655 object
656 .get(key)
657 .and_then(Value::as_str)
658 .ok_or_else(|| format!("missing required string field '{key}'"))
659 };
660 let require_f64 = |key: &str| -> Result<f64, String> {
661 object
662 .get(key)
663 .and_then(Value::as_f64)
664 .ok_or_else(|| format!("missing required number field '{key}'"))
665 };
666 match kind {
667 "finding.asserted" => {
668 require_str("proposal_id")?;
671 }
672 "finding.reviewed" => {
673 require_str("proposal_id")?;
674 let status = require_str("status")?;
675 if !matches!(
676 status,
677 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
678 ) {
679 return Err(format!("invalid review status '{status}'"));
680 }
681 }
682 "finding.noted" | "finding.caveated" => {
683 require_str("proposal_id")?;
684 require_str("annotation_id")?;
685 let text = require_str("text")?;
686 if text.trim().is_empty() {
687 return Err("payload.text must be non-empty".to_string());
688 }
689 if let Some(prov) = object.get("provenance") {
695 let prov_obj = prov
696 .as_object()
697 .ok_or("payload.provenance must be a JSON object when present")?;
698 let has_id = prov_obj
699 .get("doi")
700 .and_then(Value::as_str)
701 .is_some_and(|s| !s.trim().is_empty())
702 || prov_obj
703 .get("pmid")
704 .and_then(Value::as_str)
705 .is_some_and(|s| !s.trim().is_empty())
706 || prov_obj
707 .get("title")
708 .and_then(Value::as_str)
709 .is_some_and(|s| !s.trim().is_empty());
710 if !has_id {
711 return Err(
712 "payload.provenance must include at least one of doi/pmid/title"
713 .to_string(),
714 );
715 }
716 }
717 }
718 "finding.confidence_revised" => {
719 require_str("proposal_id")?;
720 let new_score = require_f64("new_score")?;
721 if !(0.0..=1.0).contains(&new_score) {
722 return Err(format!("new_score {new_score} out of [0.0, 1.0]"));
723 }
724 let _ = require_f64("previous_score")?;
725 }
726 "finding.rejected" => {
727 require_str("proposal_id")?;
728 }
729 "finding.superseded" => {
730 require_str("proposal_id")?;
731 require_str("new_finding_id")?;
732 }
733 "finding.retracted" => {
734 require_str("proposal_id")?;
735 if let Some(affected) = object.get("affected") {
738 let _ = affected
739 .as_u64()
740 .ok_or("affected must be a non-negative integer")?;
741 }
742 }
743 "finding.dependency_invalidated" => {
748 require_str("upstream_finding_id")?;
749 require_str("upstream_event_id")?;
750 let depth = object
751 .get("depth")
752 .and_then(Value::as_u64)
753 .ok_or("missing required positive integer 'depth'")?;
754 if depth == 0 {
755 return Err("depth must be >= 1 (genesis is the source retraction)".to_string());
756 }
757 require_str("proposal_id")?;
759 }
760 "frontier.created" => {
763 require_str("name")?;
764 require_str("creator")?;
765 }
766 "prediction.expired_unresolved" => {
773 require_str("prediction_id")?;
774 require_str("resolves_by")?;
775 require_str("expired_at")?;
776 }
777 "frontier.synced_with_peer" => {
783 require_str("peer_id")?;
784 require_str("peer_snapshot_hash")?;
785 require_str("our_snapshot_hash")?;
786 let _ = object
787 .get("divergence_count")
788 .and_then(Value::as_u64)
789 .ok_or("missing required non-negative integer 'divergence_count'")?;
790 }
791 "frontier.conflict_detected" => {
792 require_str("peer_id")?;
793 require_str("finding_id")?;
794 let kind = require_str("kind")?;
795 if kind.trim().is_empty() {
800 return Err("payload.kind must be a non-empty string".to_string());
801 }
802 }
803 "frontier.conflict_resolved" => {
807 let conflict_event_id = require_str("conflict_event_id")?;
808 if conflict_event_id.trim().is_empty() {
809 return Err("payload.conflict_event_id must be a non-empty string".to_string());
810 }
811 let resolved_by = require_str("resolved_by")?;
812 if resolved_by.trim().is_empty() {
813 return Err("payload.resolved_by must be a non-empty string".to_string());
814 }
815 let note = require_str("resolution_note")?;
816 if note.trim().is_empty() {
817 return Err("payload.resolution_note must be a non-empty string".to_string());
818 }
819 if let Some(value) = object.get("winning_proposal_id")
823 && !value.is_null()
824 && !value.is_string()
825 {
826 return Err("payload.winning_proposal_id must be a string when present".to_string());
827 }
828 }
829 "replication.deposited" => {
834 let rep = object
835 .get("replication")
836 .ok_or("payload.replication is required")?;
837 if !rep.is_object() {
838 return Err("payload.replication must be an object".to_string());
839 }
840 let id = rep
841 .get("id")
842 .and_then(Value::as_str)
843 .ok_or("payload.replication.id is required (vrep_<hex>)")?;
844 if !id.starts_with("vrep_") {
845 return Err(format!(
846 "payload.replication.id must start with 'vrep_', got '{id}'"
847 ));
848 }
849 }
850 "prediction.deposited" => {
853 let pred = object
854 .get("prediction")
855 .ok_or("payload.prediction is required")?;
856 if !pred.is_object() {
857 return Err("payload.prediction must be an object".to_string());
858 }
859 let id = pred
860 .get("id")
861 .and_then(Value::as_str)
862 .ok_or("payload.prediction.id is required (vpred_<hex>)")?;
863 if !id.starts_with("vpred_") {
864 return Err(format!(
865 "payload.prediction.id must start with 'vpred_', got '{id}'"
866 ));
867 }
868 }
869 "bridge.reviewed" => {
876 let bridge_id = require_str("bridge_id")?;
877 if !bridge_id.starts_with("vbr_") {
878 return Err(format!(
879 "payload.bridge_id must start with 'vbr_', got '{bridge_id}'"
880 ));
881 }
882 let status = require_str("status")?;
883 if !matches!(status, "confirmed" | "refuted") {
884 return Err(format!(
885 "payload.status must be 'confirmed' or 'refuted', got '{status}'"
886 ));
887 }
888 if let Some(value) = object.get("note")
890 && !value.is_null()
891 && !value.is_string()
892 {
893 return Err("payload.note must be a string when present".to_string());
894 }
895 }
896 "assertion.reinterpreted_causal" => {
905 require_str("proposal_id")?;
906 let check_block = |block_name: &str| -> Result<(), String> {
907 let block = object
908 .get(block_name)
909 .and_then(Value::as_object)
910 .ok_or_else(|| format!("payload.{block_name} must be an object"))?;
911 if let Some(claim) = block.get("claim").and_then(Value::as_str)
912 && !crate::bundle::VALID_CAUSAL_CLAIMS.contains(&claim)
913 {
914 return Err(format!(
915 "{block_name}.claim '{claim}' not in {:?}",
916 crate::bundle::VALID_CAUSAL_CLAIMS
917 ));
918 }
919 if let Some(grade) = block.get("grade").and_then(Value::as_str)
920 && !crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES.contains(&grade)
921 {
922 return Err(format!(
923 "{block_name}.grade '{grade}' not in {:?}",
924 crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES
925 ));
926 }
927 Ok(())
928 };
929 check_block("before")?;
930 check_block("after")?;
931 }
932 "finding.threshold_set" => {
938 let threshold = object
939 .get("threshold")
940 .and_then(Value::as_u64)
941 .ok_or("missing required positive integer 'threshold'")?;
942 if threshold == 0 {
943 return Err("threshold must be >= 1".to_string());
944 }
945 }
946 "finding.threshold_met" => {
947 let count = object
948 .get("signature_count")
949 .and_then(Value::as_u64)
950 .ok_or("missing required positive integer 'signature_count'")?;
951 let threshold = object
952 .get("threshold")
953 .and_then(Value::as_u64)
954 .ok_or("missing required positive integer 'threshold'")?;
955 if count < threshold {
956 return Err(format!(
957 "signature_count {count} below threshold {threshold}"
958 ));
959 }
960 }
961 EVENT_KIND_KEY_REVOKE => {
969 let revoked = require_str("revoked_pubkey")?;
970 if revoked.len() != 64 || !revoked.chars().all(|c| c.is_ascii_hexdigit()) {
971 return Err(format!(
972 "revoked_pubkey must be 64 hex chars (Ed25519 pubkey), got {} chars",
973 revoked.len()
974 ));
975 }
976 let revoked_at = require_str("revoked_at")?;
977 if revoked_at.trim().is_empty() {
978 return Err("revoked_at must be a non-empty ISO-8601 timestamp".to_string());
979 }
980 if DateTime::parse_from_rfc3339(revoked_at).is_err() {
985 return Err(format!(
986 "revoked_at must parse as RFC-3339/ISO-8601, got {revoked_at:?}"
987 ));
988 }
989 if let Some(replacement) = object.get("replacement_pubkey")
993 && let Some(rep_str) = replacement.as_str()
994 && !rep_str.is_empty()
995 && (rep_str.len() != 64 || !rep_str.chars().all(|c| c.is_ascii_hexdigit()))
996 {
997 return Err(format!(
998 "replacement_pubkey must be 64 hex chars when present, got {} chars",
999 rep_str.len()
1000 ));
1001 }
1002 if let Some(replacement) = object.get("replacement_pubkey").and_then(Value::as_str)
1005 && !replacement.is_empty()
1006 && replacement.eq_ignore_ascii_case(revoked)
1007 {
1008 return Err("replacement_pubkey must differ from revoked_pubkey".to_string());
1009 }
1010 }
1011 EVENT_KIND_NEGATIVE_RESULT_ASSERTED => {
1020 require_str("proposal_id")?;
1021 let nr = object
1022 .get("negative_result")
1023 .and_then(Value::as_object)
1024 .ok_or("payload.negative_result must be a JSON object")?;
1025 let nr_kind = nr
1026 .get("kind")
1027 .and_then(|k| k.as_object())
1028 .and_then(|k| k.get("kind"))
1029 .and_then(Value::as_str)
1030 .ok_or(
1031 "payload.negative_result.kind.kind must be 'registered_trial' or 'exploratory'",
1032 )?;
1033 match nr_kind {
1034 "registered_trial" => {
1035 let kind_obj = nr
1036 .get("kind")
1037 .and_then(Value::as_object)
1038 .expect("checked above");
1039 for k in ["endpoint", "intervention", "comparator", "population"] {
1040 let v = kind_obj
1041 .get(k)
1042 .and_then(Value::as_str)
1043 .ok_or_else(|| format!("registered_trial.{k} must be a string"))?;
1044 if v.trim().is_empty() {
1045 return Err(format!("registered_trial.{k} must be non-empty"));
1046 }
1047 }
1048 let _ = kind_obj
1049 .get("n_enrolled")
1050 .and_then(Value::as_u64)
1051 .ok_or("registered_trial.n_enrolled must be a non-negative integer")?;
1052 let power = kind_obj
1053 .get("power")
1054 .and_then(Value::as_f64)
1055 .ok_or("registered_trial.power must be a number on [0, 1]")?;
1056 if !(0.0..=1.0).contains(&power) {
1057 return Err(format!("registered_trial.power {power} out of [0.0, 1.0]"));
1058 }
1059 let ci = kind_obj
1060 .get("effect_size_ci")
1061 .and_then(Value::as_array)
1062 .ok_or("registered_trial.effect_size_ci must be a 2-element array [lower, upper]")?;
1063 if ci.len() != 2 {
1064 return Err(format!(
1065 "registered_trial.effect_size_ci must have length 2, got {}",
1066 ci.len()
1067 ));
1068 }
1069 let lower = ci[0]
1070 .as_f64()
1071 .ok_or("registered_trial.effect_size_ci[0] must be a number")?;
1072 let upper = ci[1]
1073 .as_f64()
1074 .ok_or("registered_trial.effect_size_ci[1] must be a number")?;
1075 if upper < lower {
1076 return Err(format!(
1077 "registered_trial.effect_size_ci upper {upper} below lower {lower}"
1078 ));
1079 }
1080 }
1081 "exploratory" => {
1082 let kind_obj = nr
1083 .get("kind")
1084 .and_then(Value::as_object)
1085 .expect("checked above");
1086 for k in ["reagent", "observation"] {
1087 let v = kind_obj
1088 .get(k)
1089 .and_then(Value::as_str)
1090 .ok_or_else(|| format!("exploratory.{k} must be a string"))?;
1091 if v.trim().is_empty() {
1092 return Err(format!("exploratory.{k} must be non-empty"));
1093 }
1094 }
1095 let attempts = kind_obj
1096 .get("attempts")
1097 .and_then(Value::as_u64)
1098 .ok_or("exploratory.attempts must be a positive integer")?;
1099 if attempts == 0 {
1100 return Err("exploratory.attempts must be >= 1".to_string());
1101 }
1102 }
1103 other => {
1104 return Err(format!(
1105 "negative_result.kind.kind '{other}' must be 'registered_trial' or 'exploratory'"
1106 ));
1107 }
1108 }
1109 let depositor = nr
1110 .get("deposited_by")
1111 .and_then(Value::as_str)
1112 .ok_or("payload.negative_result.deposited_by must be a non-empty string")?;
1113 if depositor.trim().is_empty() {
1114 return Err("payload.negative_result.deposited_by must be non-empty".to_string());
1115 }
1116 }
1117 EVENT_KIND_NEGATIVE_RESULT_REVIEWED => {
1118 require_str("proposal_id")?;
1119 let status = require_str("status")?;
1120 if !matches!(
1121 status,
1122 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1123 ) {
1124 return Err(format!("invalid review status '{status}'"));
1125 }
1126 }
1127 EVENT_KIND_NEGATIVE_RESULT_RETRACTED => {
1128 require_str("proposal_id")?;
1129 }
1130 EVENT_KIND_TRAJECTORY_CREATED => {
1136 require_str("proposal_id")?;
1137 let traj = object
1138 .get("trajectory")
1139 .and_then(Value::as_object)
1140 .ok_or("payload.trajectory must be a JSON object")?;
1141 let depositor = traj
1142 .get("deposited_by")
1143 .and_then(Value::as_str)
1144 .ok_or("payload.trajectory.deposited_by must be a non-empty string")?;
1145 if depositor.trim().is_empty() {
1146 return Err("payload.trajectory.deposited_by must be non-empty".to_string());
1147 }
1148 let id = traj
1149 .get("id")
1150 .and_then(Value::as_str)
1151 .ok_or("payload.trajectory.id must be a vtr_<hex>")?;
1152 if !id.starts_with("vtr_") {
1153 return Err(format!(
1154 "payload.trajectory.id must start with 'vtr_', got '{id}'"
1155 ));
1156 }
1157 }
1158 EVENT_KIND_TRAJECTORY_STEP_APPENDED => {
1159 require_str("proposal_id")?;
1160 let parent = require_str("parent_trajectory_id")?;
1161 if !parent.starts_with("vtr_") {
1162 return Err(format!(
1163 "parent_trajectory_id must start with 'vtr_', got '{parent}'"
1164 ));
1165 }
1166 let step = object
1167 .get("step")
1168 .and_then(Value::as_object)
1169 .ok_or("payload.step must be a JSON object")?;
1170 let kind_str = step.get("kind").and_then(Value::as_str).ok_or(
1171 "payload.step.kind must be one of hypothesis|tried|ruled_out|observed|refined",
1172 )?;
1173 if !matches!(
1174 kind_str,
1175 "hypothesis" | "tried" | "ruled_out" | "observed" | "refined"
1176 ) {
1177 return Err(format!(
1178 "payload.step.kind '{kind_str}' must be one of hypothesis|tried|ruled_out|observed|refined"
1179 ));
1180 }
1181 let description = step
1182 .get("description")
1183 .and_then(Value::as_str)
1184 .ok_or("payload.step.description must be a non-empty string")?;
1185 if description.trim().is_empty() {
1186 return Err("payload.step.description must be non-empty".to_string());
1187 }
1188 let actor = step
1189 .get("actor")
1190 .and_then(Value::as_str)
1191 .ok_or("payload.step.actor must be a non-empty string")?;
1192 if actor.trim().is_empty() {
1193 return Err("payload.step.actor must be non-empty".to_string());
1194 }
1195 }
1196 EVENT_KIND_TRAJECTORY_REVIEWED => {
1197 require_str("proposal_id")?;
1198 let status = require_str("status")?;
1199 if !matches!(
1200 status,
1201 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1202 ) {
1203 return Err(format!("invalid review status '{status}'"));
1204 }
1205 }
1206 EVENT_KIND_TRAJECTORY_RETRACTED => {
1207 require_str("proposal_id")?;
1208 }
1209 EVENT_KIND_ARTIFACT_ASSERTED => {
1210 require_str("proposal_id")?;
1211 let artifact = object
1212 .get("artifact")
1213 .and_then(Value::as_object)
1214 .ok_or("payload.artifact must be a JSON object")?;
1215 let id = artifact
1216 .get("id")
1217 .and_then(Value::as_str)
1218 .ok_or("payload.artifact.id must be a va_<hex>")?;
1219 if !id.starts_with("va_") {
1220 return Err(format!(
1221 "payload.artifact.id must start with 'va_', got '{id}'"
1222 ));
1223 }
1224 let id_hex = id.trim_start_matches("va_");
1225 if id_hex.len() != 16 || !id_hex.chars().all(|c| c.is_ascii_hexdigit()) {
1226 return Err("payload.artifact.id must be va_<16hex>".to_string());
1227 }
1228 let kind = artifact
1229 .get("kind")
1230 .and_then(Value::as_str)
1231 .ok_or("payload.artifact.kind must be a string")?;
1232 if !crate::bundle::valid_artifact_kind(kind) {
1233 return Err(format!("payload.artifact.kind '{kind}' is not supported"));
1234 }
1235 for key in ["name", "content_hash", "storage_mode"] {
1236 let value = artifact
1237 .get(key)
1238 .and_then(Value::as_str)
1239 .ok_or_else(|| format!("payload.artifact.{key} must be a string"))?;
1240 if value.trim().is_empty() {
1241 return Err(format!("payload.artifact.{key} must be non-empty"));
1242 }
1243 }
1244 let content_hash = artifact
1245 .get("content_hash")
1246 .and_then(Value::as_str)
1247 .expect("content_hash checked above");
1248 validate_sha256_commitment("payload.artifact.content_hash", content_hash)?;
1249 }
1250 EVENT_KIND_ARTIFACT_REVIEWED => {
1251 require_str("proposal_id")?;
1252 let status = require_str("status")?;
1253 if !matches!(
1254 status,
1255 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1256 ) {
1257 return Err(format!("invalid review status '{status}'"));
1258 }
1259 }
1260 EVENT_KIND_ARTIFACT_RETRACTED => {
1261 require_str("proposal_id")?;
1262 }
1263 EVENT_KIND_TIER_SET => {
1269 require_str("proposal_id")?;
1270 let object_type = require_str("object_type")?;
1271 if !matches!(
1272 object_type,
1273 "finding" | "negative_result" | "trajectory" | "artifact"
1274 ) {
1275 return Err(format!(
1276 "tier.set object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1277 ));
1278 }
1279 require_str("object_id")?;
1280 let new_tier = require_str("new_tier")?;
1281 crate::access_tier::AccessTier::parse(new_tier)?;
1282 if let Some(prev) = object.get("previous_tier").and_then(Value::as_str) {
1287 crate::access_tier::AccessTier::parse(prev)?;
1288 }
1289 }
1290 EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED => {
1296 require_str("proposal_id")?;
1297 let source_id = require_str("source_id")?;
1298 if source_id.trim().is_empty() {
1299 return Err("payload.source_id must be non-empty".to_string());
1300 }
1301 let locator = require_str("locator")?;
1302 if locator.trim().is_empty() {
1303 return Err("payload.locator must be non-empty".to_string());
1304 }
1305 }
1306 EVENT_KIND_FINDING_SPAN_REPAIRED => {
1309 require_str("proposal_id")?;
1310 let section = require_str("section")?;
1311 if section.trim().is_empty() {
1312 return Err("payload.section must be non-empty".to_string());
1313 }
1314 let text = require_str("text")?;
1315 if text.trim().is_empty() {
1316 return Err("payload.text must be non-empty".to_string());
1317 }
1318 }
1319 EVENT_KIND_FINDING_ENTITY_RESOLVED => {
1323 require_str("proposal_id")?;
1324 let entity_name = require_str("entity_name")?;
1325 if entity_name.trim().is_empty() {
1326 return Err("payload.entity_name must be non-empty".to_string());
1327 }
1328 let source = require_str("source")?;
1329 if source.trim().is_empty() {
1330 return Err("payload.source must be non-empty".to_string());
1331 }
1332 let id = require_str("id")?;
1333 if id.trim().is_empty() {
1334 return Err("payload.id must be non-empty".to_string());
1335 }
1336 let confidence = require_f64("confidence")?;
1337 if !(0.0..=1.0).contains(&confidence) {
1338 return Err(format!("payload.confidence {confidence} out of [0.0, 1.0]"));
1339 }
1340 }
1341 other => return Err(format!("unknown event kind '{other}'")),
1342 }
1343 Ok(())
1344}
1345
1346pub fn validate_bridge_reviewed_against_state(
1364 payload: &Value,
1365 known_bridge_ids: &[String],
1366) -> Result<(), String> {
1367 let object = payload
1368 .as_object()
1369 .ok_or_else(|| "payload must be a JSON object".to_string())?;
1370 let bridge_id = object
1371 .get("bridge_id")
1372 .and_then(Value::as_str)
1373 .ok_or_else(|| "missing required string field 'bridge_id'".to_string())?;
1374 if !known_bridge_ids.iter().any(|id| id == bridge_id) {
1375 return Err(format!(
1376 "bridge_id '{bridge_id}' not present on this frontier (no matching .vela/bridges/<id>.json)"
1377 ));
1378 }
1379 Ok(())
1380}
1381
1382pub fn compute_event_id(event: &StateEvent) -> String {
1387 event_id(event)
1388}
1389
1390fn event_id(event: &StateEvent) -> String {
1391 let content = json!({
1392 "schema": event.schema,
1393 "kind": event.kind,
1394 "target": event.target,
1395 "actor": event.actor,
1396 "timestamp": event.timestamp,
1397 "reason": event.reason,
1398 "before_hash": event.before_hash,
1399 "after_hash": event.after_hash,
1400 "payload": event.payload,
1401 "caveats": event.caveats,
1402 });
1403 let bytes = canonical::to_canonical_bytes(&content).unwrap_or_default();
1404 format!("vev_{}", &hex::encode(Sha256::digest(bytes))[..16])
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::bundle::{
1411 Assertion, Conditions, Confidence, Evidence, Extraction, FindingBundle, Flags, Provenance,
1412 };
1413 use crate::project;
1414
1415 fn finding() -> FindingBundle {
1416 FindingBundle::new(
1417 Assertion {
1418 text: "LRP1 clears amyloid beta at the BBB".to_string(),
1419 assertion_type: "mechanism".to_string(),
1420 entities: Vec::new(),
1421 relation: None,
1422 direction: None,
1423 causal_claim: None,
1424 causal_evidence_grade: None,
1425 },
1426 Evidence {
1427 evidence_type: "experimental".to_string(),
1428 model_system: "mouse".to_string(),
1429 species: Some("Mus musculus".to_string()),
1430 method: "assay".to_string(),
1431 sample_size: None,
1432 effect_size: None,
1433 p_value: None,
1434 replicated: false,
1435 replication_count: None,
1436 evidence_spans: Vec::new(),
1437 },
1438 Conditions {
1439 text: "mouse model".to_string(),
1440 species_verified: Vec::new(),
1441 species_unverified: Vec::new(),
1442 in_vitro: false,
1443 in_vivo: true,
1444 human_data: false,
1445 clinical_trial: false,
1446 concentration_range: None,
1447 duration: None,
1448 age_group: None,
1449 cell_type: None,
1450 },
1451 Confidence::raw(0.6, "test", 0.8),
1452 Provenance {
1453 source_type: "published_paper".to_string(),
1454 doi: None,
1455 pmid: None,
1456 pmc: None,
1457 openalex_id: None,
1458 url: None,
1459 title: "Test source".to_string(),
1460 authors: Vec::new(),
1461 year: Some(2026),
1462 journal: None,
1463 license: None,
1464 publisher: None,
1465 funders: Vec::new(),
1466 extraction: Extraction::default(),
1467 review: None,
1468 citation_count: None,
1469 },
1470 Flags {
1471 gap: false,
1472 negative_space: false,
1473 contested: false,
1474 retracted: false,
1475 declining: false,
1476 gravity_well: false,
1477 review_state: None,
1478 superseded: false,
1479 signature_threshold: None,
1480 jointly_accepted: false,
1481 },
1482 )
1483 }
1484
1485 #[test]
1486 fn event_id_is_deterministic_for_content() {
1487 let event = new_finding_event(FindingEventInput {
1488 kind: "finding.reviewed",
1489 finding_id: "vf_test",
1490 actor_id: "reviewer",
1491 actor_type: "human",
1492 reason: "checked",
1493 before_hash: NULL_HASH,
1494 after_hash: "sha256:abc",
1495 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1496 caveats: vec![],
1497 });
1498 let mut same = event.clone();
1499 same.id = String::new();
1500 same.id = super::event_id(&same);
1501 assert_eq!(event.id, same.id);
1502 }
1503
1504 #[test]
1505 fn genesis_only_event_log_replays_ok() {
1506 let frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1511 let report = replay_report(&frontier);
1512 assert!(report.ok, "{:?}", report.conflicts);
1513 assert_eq!(report.event_log.count, 1);
1514 assert_eq!(report.event_log.kinds.get("frontier.created"), Some(&1));
1515 }
1516
1517 #[test]
1518 fn replay_detects_duplicate_event_ids() {
1519 let finding = finding();
1520 let after_hash = finding_hash(&finding);
1521 let event = new_finding_event(FindingEventInput {
1522 kind: "finding.reviewed",
1523 finding_id: &finding.id,
1524 actor_id: "reviewer",
1525 actor_type: "human",
1526 reason: "checked",
1527 before_hash: &after_hash,
1528 after_hash: &after_hash,
1529 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1530 caveats: vec![],
1531 });
1532 let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1533 frontier.events = vec![event.clone(), event];
1534
1535 let report = replay_report(&frontier);
1536 assert!(!report.ok);
1537 assert_eq!(report.status, "conflict");
1538 assert!(!report.event_log.duplicate_ids.is_empty());
1539 }
1540
1541 #[test]
1542 fn replay_detects_orphan_targets() {
1543 let mut frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1544 frontier.events.push(new_finding_event(FindingEventInput {
1545 kind: "finding.reviewed",
1546 finding_id: "vf_missing",
1547 actor_id: "reviewer",
1548 actor_type: "human",
1549 reason: "checked",
1550 before_hash: NULL_HASH,
1551 after_hash: "sha256:abc",
1552 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1553 caveats: vec![],
1554 }));
1555
1556 let report = replay_report(&frontier);
1557 assert!(!report.ok);
1558 assert_eq!(report.event_log.orphan_targets, vec!["vf_missing"]);
1559 }
1560
1561 #[test]
1562 fn replay_accepts_current_hash_boundary() {
1563 let finding = finding();
1564 let hash = finding_hash(&finding);
1565 let event = new_finding_event(FindingEventInput {
1566 kind: "finding.reviewed",
1567 finding_id: &finding.id,
1568 actor_id: "reviewer",
1569 actor_type: "human",
1570 reason: "checked",
1571 before_hash: &hash,
1572 after_hash: &hash,
1573 payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1574 caveats: vec![],
1575 });
1576 let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1577 frontier.events.push(event);
1578
1579 let report = replay_report(&frontier);
1580 assert!(report.ok, "{:?}", report.conflicts);
1581 assert_eq!(report.status, "ok");
1582 }
1583
1584 #[test]
1586 fn validates_synced_with_peer_payload() {
1587 assert!(
1589 validate_event_payload(
1590 "frontier.synced_with_peer",
1591 &json!({
1592 "peer_id": "hub:peer",
1593 "peer_snapshot_hash": "abc",
1594 "our_snapshot_hash": "def",
1595 "divergence_count": 3,
1596 }),
1597 )
1598 .is_ok()
1599 );
1600 assert!(
1602 validate_event_payload(
1603 "frontier.synced_with_peer",
1604 &json!({
1605 "peer_id": "hub:peer",
1606 "peer_snapshot_hash": "abc",
1607 "our_snapshot_hash": "def",
1608 }),
1609 )
1610 .is_err()
1611 );
1612 assert!(
1614 validate_event_payload(
1615 "frontier.synced_with_peer",
1616 &json!({
1617 "peer_snapshot_hash": "abc",
1618 "our_snapshot_hash": "def",
1619 "divergence_count": 0,
1620 }),
1621 )
1622 .is_err()
1623 );
1624 }
1625
1626 #[test]
1627 fn validates_conflict_detected_payload() {
1628 assert!(
1630 validate_event_payload(
1631 "frontier.conflict_detected",
1632 &json!({
1633 "peer_id": "hub:peer",
1634 "finding_id": "vf_xyz",
1635 "kind": "different_review_verdict",
1636 }),
1637 )
1638 .is_ok()
1639 );
1640 assert!(
1642 validate_event_payload(
1643 "frontier.conflict_detected",
1644 &json!({
1645 "peer_id": "hub:peer",
1646 "finding_id": "vf_xyz",
1647 "kind": " ",
1648 }),
1649 )
1650 .is_err()
1651 );
1652 assert!(
1654 validate_event_payload(
1655 "frontier.conflict_detected",
1656 &json!({
1657 "peer_id": "hub:peer",
1658 "kind": "missing_in_peer",
1659 }),
1660 )
1661 .is_err()
1662 );
1663 }
1664
1665 #[test]
1666 fn validates_artifact_asserted_payload() {
1667 let good_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1668 assert!(
1669 validate_event_payload(
1670 EVENT_KIND_ARTIFACT_ASSERTED,
1671 &json!({
1672 "proposal_id": "vpr_test",
1673 "artifact": {
1674 "id": "va_1234567890abcdef",
1675 "kind": "clinical_trial_record",
1676 "name": "NCT test record",
1677 "content_hash": good_hash,
1678 "storage_mode": "embedded",
1679 },
1680 }),
1681 )
1682 .is_ok()
1683 );
1684 assert!(
1685 validate_event_payload(
1686 EVENT_KIND_ARTIFACT_ASSERTED,
1687 &json!({
1688 "proposal_id": "vpr_test",
1689 "artifact": {
1690 "id": "va_123",
1691 "kind": "clinical_trial_record",
1692 "name": "NCT test record",
1693 "content_hash": good_hash,
1694 "storage_mode": "embedded",
1695 },
1696 }),
1697 )
1698 .is_err()
1699 );
1700 assert!(
1701 validate_event_payload(
1702 EVENT_KIND_ARTIFACT_ASSERTED,
1703 &json!({
1704 "proposal_id": "vpr_test",
1705 "artifact": {
1706 "id": "va_1234567890abcdef",
1707 "kind": "clinical_trial_record",
1708 "name": "NCT test record",
1709 "content_hash": "sha256:not-a-real-hash",
1710 "storage_mode": "embedded",
1711 },
1712 }),
1713 )
1714 .is_err()
1715 );
1716 }
1717
1718 #[test]
1720 fn validates_reinterpreted_causal_payload() {
1721 assert!(
1723 validate_event_payload(
1724 "assertion.reinterpreted_causal",
1725 &json!({
1726 "proposal_id": "vpr_test",
1727 "before": {},
1728 "after": { "claim": "intervention", "grade": "rct" },
1729 }),
1730 )
1731 .is_ok()
1732 );
1733 assert!(
1735 validate_event_payload(
1736 "assertion.reinterpreted_causal",
1737 &json!({
1738 "proposal_id": "vpr_test",
1739 "before": { "claim": "correlation" },
1740 "after": { "claim": "mediation" },
1741 }),
1742 )
1743 .is_ok()
1744 );
1745 assert!(
1747 validate_event_payload(
1748 "assertion.reinterpreted_causal",
1749 &json!({
1750 "proposal_id": "vpr_test",
1751 "before": {},
1752 "after": { "claim": "magic" },
1753 }),
1754 )
1755 .is_err()
1756 );
1757 assert!(
1759 validate_event_payload(
1760 "assertion.reinterpreted_causal",
1761 &json!({
1762 "proposal_id": "vpr_test",
1763 "before": {},
1764 "after": { "claim": "intervention", "grade": "vibes" },
1765 }),
1766 )
1767 .is_err()
1768 );
1769 assert!(
1771 validate_event_payload(
1772 "assertion.reinterpreted_causal",
1773 &json!({
1774 "before": {},
1775 "after": { "claim": "intervention" },
1776 }),
1777 )
1778 .is_err()
1779 );
1780 }
1781
1782 #[test]
1787 fn revocation_event_canonical_shape() {
1788 use crate::canonical;
1789 let payload = RevocationPayload {
1790 revoked_pubkey: "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e"
1791 .to_string(),
1792 revoked_at: "2026-05-01T17:00:00Z".to_string(),
1793 replacement_pubkey: "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9"
1794 .to_string(),
1795 reason: "key file leaked from CI cache".to_string(),
1796 };
1797 let event = new_revocation_event(
1798 "reviewer:will-blair",
1799 "human",
1800 payload,
1801 "rotating compromised key",
1802 NULL_HASH,
1803 NULL_HASH,
1804 );
1805 assert_eq!(event.kind, EVENT_KIND_KEY_REVOKE);
1806 assert_eq!(event.target.r#type, "actor");
1807 assert!(event.id.starts_with("vev_"));
1808 let bytes = canonical::to_canonical_bytes(&event).unwrap();
1809 let s = std::str::from_utf8(&bytes).unwrap();
1810 assert!(
1811 s.contains("\"revoked_pubkey\""),
1812 "canonical bytes missing revoked_pubkey: {s}"
1813 );
1814 assert!(
1815 s.contains("\"revoked_at\""),
1816 "canonical bytes missing revoked_at: {s}"
1817 );
1818 assert!(
1819 s.contains("\"replacement_pubkey\""),
1820 "canonical bytes missing replacement_pubkey: {s}"
1821 );
1822
1823 let payload_minimal = RevocationPayload {
1825 revoked_pubkey: "a".repeat(64),
1826 revoked_at: "2026-05-01T17:00:00Z".to_string(),
1827 replacement_pubkey: String::new(),
1828 reason: String::new(),
1829 };
1830 let minimal_event = new_revocation_event(
1831 "reviewer:will-blair",
1832 "human",
1833 payload_minimal,
1834 "scheduled rotation",
1835 NULL_HASH,
1836 NULL_HASH,
1837 );
1838 let minimal_bytes = canonical::to_canonical_bytes(&minimal_event).unwrap();
1839 let minimal_s = std::str::from_utf8(&minimal_bytes).unwrap();
1840 assert!(
1841 !minimal_s.contains("\"replacement_pubkey\""),
1842 "empty replacement_pubkey leaked into canonical JSON: {minimal_s}"
1843 );
1844 assert!(
1845 !minimal_s.contains("\"reason\":\"\""),
1846 "empty payload reason leaked into canonical JSON: {minimal_s}"
1847 );
1848 }
1849
1850 #[test]
1853 fn revocation_payload_validation() {
1854 let good_pubkey = "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e";
1855 let other_pubkey = "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9";
1856
1857 assert!(
1859 validate_event_payload(
1860 EVENT_KIND_KEY_REVOKE,
1861 &json!({
1862 "revoked_pubkey": good_pubkey,
1863 "revoked_at": "2026-05-01T17:00:00Z",
1864 }),
1865 )
1866 .is_ok()
1867 );
1868
1869 assert!(
1871 validate_event_payload(
1872 EVENT_KIND_KEY_REVOKE,
1873 &json!({
1874 "revoked_pubkey": good_pubkey,
1875 "revoked_at": "2026-05-01T17:00:00Z",
1876 "replacement_pubkey": other_pubkey,
1877 "reason": "key file leaked",
1878 }),
1879 )
1880 .is_ok()
1881 );
1882
1883 assert!(
1885 validate_event_payload(
1886 EVENT_KIND_KEY_REVOKE,
1887 &json!({
1888 "revoked_pubkey": "abc123",
1889 "revoked_at": "2026-05-01T17:00:00Z",
1890 }),
1891 )
1892 .is_err()
1893 );
1894
1895 assert!(
1897 validate_event_payload(
1898 EVENT_KIND_KEY_REVOKE,
1899 &json!({
1900 "revoked_pubkey": "ZZ".repeat(32),
1901 "revoked_at": "2026-05-01T17:00:00Z",
1902 }),
1903 )
1904 .is_err()
1905 );
1906
1907 assert!(
1909 validate_event_payload(
1910 EVENT_KIND_KEY_REVOKE,
1911 &json!({
1912 "revoked_pubkey": good_pubkey,
1913 }),
1914 )
1915 .is_err()
1916 );
1917
1918 assert!(
1920 validate_event_payload(
1921 EVENT_KIND_KEY_REVOKE,
1922 &json!({
1923 "revoked_pubkey": good_pubkey,
1924 "revoked_at": "2026-05-01T17:00:00Z",
1925 "replacement_pubkey": "deadbeef",
1926 }),
1927 )
1928 .is_err()
1929 );
1930
1931 assert!(
1933 validate_event_payload(
1934 EVENT_KIND_KEY_REVOKE,
1935 &json!({
1936 "revoked_pubkey": good_pubkey,
1937 "revoked_at": "2026-05-01T17:00:00Z",
1938 "replacement_pubkey": good_pubkey,
1939 }),
1940 )
1941 .is_err()
1942 );
1943
1944 for bad in [
1952 "yesterday",
1953 "2026-13-01T00:00:00Z", "2026-05-01", "x",
1956 ] {
1957 assert!(
1958 validate_event_payload(
1959 EVENT_KIND_KEY_REVOKE,
1960 &json!({
1961 "revoked_pubkey": good_pubkey,
1962 "revoked_at": bad,
1963 }),
1964 )
1965 .is_err(),
1966 "expected revoked_at {bad:?} to fail validation"
1967 );
1968 }
1969 }
1970
1971 #[test]
1976 fn bridge_reviewed_state_aware_rejects_unknown_id() {
1977 let known: Vec<String> = vec!["vbr_aaaaaaaaaaaaaaaa".to_string()];
1978
1979 assert!(
1981 validate_bridge_reviewed_against_state(
1982 &json!({
1983 "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
1984 "status": "confirmed",
1985 }),
1986 &known,
1987 )
1988 .is_ok()
1989 );
1990
1991 let err = validate_bridge_reviewed_against_state(
1993 &json!({
1994 "bridge_id": "vbr_bbbbbbbbbbbbbbbb",
1995 "status": "confirmed",
1996 }),
1997 &known,
1998 )
1999 .expect_err("expected unknown bridge_id to be rejected");
2000 assert!(
2001 err.contains("not present on this frontier"),
2002 "error should explain the gap: {err}"
2003 );
2004
2005 assert!(
2009 validate_bridge_reviewed_against_state(
2010 &json!({
2011 "status": "confirmed",
2012 }),
2013 &known,
2014 )
2015 .is_err()
2016 );
2017
2018 assert!(
2021 validate_bridge_reviewed_against_state(
2022 &json!({
2023 "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
2024 "status": "confirmed",
2025 }),
2026 &[],
2027 )
2028 .is_err()
2029 );
2030 }
2031}