use serde_json::Value;
use crate::provenance_poly::ProvenancePoly;
use crate::status_provenance::{BelnapStatus, StatusProvenance};
#[derive(Debug, Clone)]
pub struct ProvenanceEventRef<'a> {
pub id: &'a str,
pub kind: &'a str,
pub finding_id: &'a str,
pub payload: &'a Value,
}
pub fn compute_status_provenance<'a, I>(events: I) -> StatusProvenance
where
I: IntoIterator<Item = ProvenanceEventRef<'a>>,
{
let mut sp = StatusProvenance::empty();
let mut prior_event_ids: Vec<String> = Vec::new();
let mut retract_pending: bool = false;
for ev in events {
let kind = ev.kind;
let event_id = ev.id;
match kind {
"finding.asserted" => {
sp.add_support(&ProvenancePoly::singleton(event_id));
prior_event_ids.push(event_id.to_string());
}
"finding.reviewed" => {
let status = ev
.payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("");
match status {
"accepted" | "needs_revision" => {
sp.add_support(&ProvenancePoly::singleton(event_id));
}
"contested" | "rejected" => {
sp.add_refute(&ProvenancePoly::singleton(event_id));
}
_ => {
}
}
prior_event_ids.push(event_id.to_string());
}
"finding.rejected" => {
sp.add_refute(&ProvenancePoly::singleton(event_id));
prior_event_ids.push(event_id.to_string());
}
"finding.retracted" => {
retract_pending = true;
}
_ => {
}
}
}
if retract_pending {
let retracted: std::collections::BTreeSet<String> = prior_event_ids.into_iter().collect();
sp = sp.retract(&retracted);
}
sp
}
pub fn compute_belnap_status<'a, I>(events: I) -> BelnapStatus
where
I: IntoIterator<Item = ProvenanceEventRef<'a>>,
{
compute_status_provenance(events).derive_status()
}
pub fn status_provenance_for_finding(
project: &crate::project::Project,
finding_id: &str,
) -> StatusProvenance {
let refs: Vec<ProvenanceEventRef<'_>> = project
.events
.iter()
.filter(|e| e.target.id == finding_id && e.target.r#type == "finding")
.map(|e| ProvenanceEventRef {
id: &e.id,
kind: &e.kind,
finding_id: &e.target.id,
payload: &e.payload,
})
.collect();
compute_status_provenance(refs)
}
pub fn belnap_status_for_finding(
project: &crate::project::Project,
finding_id: &str,
) -> BelnapStatus {
status_provenance_for_finding(project, finding_id).derive_status()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ev<'a>(
id: &'a str,
kind: &'a str,
finding_id: &'a str,
payload: &'a Value,
) -> ProvenanceEventRef<'a> {
ProvenanceEventRef {
id,
kind,
finding_id,
payload,
}
}
#[test]
fn empty_event_log_yields_n() {
let events: Vec<ProvenanceEventRef> = vec![];
assert_eq!(compute_belnap_status(events), BelnapStatus::None);
}
#[test]
fn finding_asserted_yields_t() {
let null = json!(null);
let events = vec![ev("vev_001", "finding.asserted", "vf_x", &null)];
assert_eq!(compute_belnap_status(events), BelnapStatus::True);
}
#[test]
fn accepted_review_keeps_t() {
let null = json!(null);
let accepted = json!({"status": "accepted"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &accepted),
];
let sp = compute_status_provenance(events);
assert_eq!(sp.derive_status(), BelnapStatus::True);
assert_eq!(sp.support.term_count(), 2);
assert!(sp.refute.is_zero());
}
#[test]
fn contested_review_promotes_to_b() {
let null = json!(null);
let contested = json!({"status": "contested"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &contested),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
}
#[test]
fn rejected_review_promotes_to_b() {
let null = json!(null);
let rejected = json!({"status": "rejected"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &rejected),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
}
#[test]
fn finding_rejected_event_adds_refute() {
let null = json!(null);
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.rejected", "vf_x", &null),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
}
#[test]
fn retraction_drops_all_prior_support_to_n() {
let null = json!(null);
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.retracted", "vf_x", &null),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::None);
}
#[test]
fn retraction_drops_refute_too() {
let null = json!(null);
let rejected = json!({"status": "rejected"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &rejected),
ev("vev_003", "finding.retracted", "vf_x", &null),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::None);
}
#[test]
fn needs_revision_keeps_t_not_b() {
let null = json!(null);
let nr = json!({"status": "needs_revision"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &nr),
];
assert_eq!(compute_belnap_status(events), BelnapStatus::True);
}
#[test]
fn unknown_review_status_is_ignored() {
let null = json!(null);
let weird = json!({"status": "potato"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &weird),
];
let sp = compute_status_provenance(events);
assert_eq!(sp.derive_status(), BelnapStatus::True);
assert_eq!(sp.support.term_count(), 1);
}
#[test]
fn support_polynomial_records_all_supporting_event_ids() {
let null = json!(null);
let accepted = json!({"status": "accepted"});
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.reviewed", "vf_x", &accepted),
ev("vev_003", "finding.reviewed", "vf_x", &accepted),
];
let sp = compute_status_provenance(events);
assert_eq!(sp.support.term_count(), 3);
let support_vars = sp.support.support();
assert!(support_vars.contains("vev_001"));
assert!(support_vars.contains("vev_002"));
assert!(support_vars.contains("vev_003"));
}
fn synthetic_project(
_finding_id: &str,
events: Vec<crate::events::StateEvent>,
) -> crate::project::Project {
let mut p = crate::project::assemble("test-frontier", vec![], 0, 0, "test");
p.events.clear();
p.events = events;
p
}
fn synthetic_event(
id: &str,
kind: &str,
finding_id: &str,
status: Option<&str>,
) -> crate::events::StateEvent {
use crate::events::{StateActor, StateEvent, StateTarget};
let payload = match status {
Some(s) => json!({"status": s}),
None => json!(null),
};
StateEvent {
schema: "vela.event.v0.1".into(),
id: id.to_string(),
kind: kind.to_string(),
target: StateTarget {
r#type: "finding".into(),
id: finding_id.to_string(),
},
actor: StateActor {
id: "reviewer:test".into(),
r#type: "human".into(),
},
timestamp: "2026-05-09T00:00:00Z".into(),
reason: "test".into(),
before_hash: String::new(),
after_hash: String::new(),
payload,
caveats: vec![],
signature: None,
schema_artifact_id: None,
}
}
#[test]
fn project_level_helper_filters_by_finding_id() {
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.asserted", "vf_y", None),
];
let p = synthetic_project("vf_x", events);
let sp = status_provenance_for_finding(&p, "vf_x");
assert_eq!(sp.derive_status(), BelnapStatus::True);
assert_eq!(sp.support.term_count(), 1);
assert!(sp.support.support().contains("vev_001"));
assert!(!sp.support.support().contains("vev_002"));
}
#[test]
fn project_level_helper_handles_full_chain() {
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("contested")),
];
let p = synthetic_project("vf_x", events);
let belnap = belnap_status_for_finding(&p, "vf_x");
assert_eq!(belnap, BelnapStatus::Both);
}
#[test]
fn project_level_helper_with_no_events_yields_n() {
let p = synthetic_project("vf_x", vec![]);
assert_eq!(belnap_status_for_finding(&p, "vf_x"), BelnapStatus::None);
}
#[test]
fn unrelated_event_kinds_do_not_affect_status() {
let null = json!(null);
let events = vec![
ev("vev_001", "finding.asserted", "vf_x", &null),
ev("vev_002", "finding.entity_added", "vf_x", &null),
ev("vev_003", "finding.span_repaired", "vf_x", &null),
];
let sp = compute_status_provenance(events);
assert_eq!(sp.derive_status(), BelnapStatus::True);
assert_eq!(sp.support.term_count(), 1);
}
}