1use serde_json::Value;
45
46use crate::provenance_poly::ProvenancePoly;
47use crate::status_provenance::{BelnapStatus, StatusProvenance};
48
49#[derive(Debug, Clone)]
56pub struct ProvenanceEventRef<'a> {
57 pub id: &'a str,
60 pub kind: &'a str,
62 pub finding_id: &'a str,
65 pub payload: &'a Value,
67}
68
69pub fn compute_status_provenance<'a, I>(events: I) -> StatusProvenance
77where
78 I: IntoIterator<Item = ProvenanceEventRef<'a>>,
79{
80 let mut sp = StatusProvenance::empty();
81
82 let mut prior_event_ids: Vec<String> = Vec::new();
89 let mut retract_pending: bool = false;
90
91 for ev in events {
92 let kind = ev.kind;
93 let event_id = ev.id;
94
95 match kind {
96 "finding.asserted" => {
97 sp.add_support(&ProvenancePoly::singleton(event_id));
98 prior_event_ids.push(event_id.to_string());
99 }
100 "finding.reviewed" => {
101 let status = ev
102 .payload
103 .get("status")
104 .and_then(Value::as_str)
105 .unwrap_or("");
106 match status {
107 "accepted" | "needs_revision" => {
108 sp.add_support(&ProvenancePoly::singleton(event_id));
109 }
110 "contested" | "rejected" => {
111 sp.add_refute(&ProvenancePoly::singleton(event_id));
112 }
113 _ => {
114 }
117 }
118 prior_event_ids.push(event_id.to_string());
119 }
120 "finding.rejected" => {
121 sp.add_refute(&ProvenancePoly::singleton(event_id));
122 prior_event_ids.push(event_id.to_string());
123 }
124 "finding.retracted" => {
125 retract_pending = true;
128 }
129 _ => {
130 }
135 }
136 }
137
138 if retract_pending {
139 let retracted: std::collections::BTreeSet<String> = prior_event_ids.into_iter().collect();
140 sp = sp.retract(&retracted);
141 }
142
143 sp
144}
145
146pub fn compute_belnap_status<'a, I>(events: I) -> BelnapStatus
150where
151 I: IntoIterator<Item = ProvenanceEventRef<'a>>,
152{
153 compute_status_provenance(events).derive_status()
154}
155
156pub fn status_provenance_for_finding(
168 project: &crate::project::Project,
169 finding_id: &str,
170) -> StatusProvenance {
171 let refs: Vec<ProvenanceEventRef<'_>> = project
172 .events
173 .iter()
174 .filter(|e| e.target.id == finding_id && e.target.r#type == "finding")
175 .map(|e| ProvenanceEventRef {
176 id: &e.id,
177 kind: &e.kind,
178 finding_id: &e.target.id,
179 payload: &e.payload,
180 })
181 .collect();
182 compute_status_provenance(refs)
183}
184
185pub fn belnap_status_for_finding(
189 project: &crate::project::Project,
190 finding_id: &str,
191) -> BelnapStatus {
192 status_provenance_for_finding(project, finding_id).derive_status()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use serde_json::json;
199
200 fn ev<'a>(
201 id: &'a str,
202 kind: &'a str,
203 finding_id: &'a str,
204 payload: &'a Value,
205 ) -> ProvenanceEventRef<'a> {
206 ProvenanceEventRef {
207 id,
208 kind,
209 finding_id,
210 payload,
211 }
212 }
213
214 #[test]
215 fn empty_event_log_yields_n() {
216 let events: Vec<ProvenanceEventRef> = vec![];
217 assert_eq!(compute_belnap_status(events), BelnapStatus::None);
218 }
219
220 #[test]
221 fn finding_asserted_yields_t() {
222 let null = json!(null);
223 let events = vec![ev("vev_001", "finding.asserted", "vf_x", &null)];
224 assert_eq!(compute_belnap_status(events), BelnapStatus::True);
225 }
226
227 #[test]
228 fn accepted_review_keeps_t() {
229 let null = json!(null);
230 let accepted = json!({"status": "accepted"});
231 let events = vec![
232 ev("vev_001", "finding.asserted", "vf_x", &null),
233 ev("vev_002", "finding.reviewed", "vf_x", &accepted),
234 ];
235 let sp = compute_status_provenance(events);
236 assert_eq!(sp.derive_status(), BelnapStatus::True);
237 assert_eq!(sp.support.term_count(), 2);
238 assert!(sp.refute.is_zero());
239 }
240
241 #[test]
242 fn contested_review_promotes_to_b() {
243 let null = json!(null);
244 let contested = json!({"status": "contested"});
245 let events = vec![
246 ev("vev_001", "finding.asserted", "vf_x", &null),
247 ev("vev_002", "finding.reviewed", "vf_x", &contested),
248 ];
249 assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
250 }
251
252 #[test]
253 fn rejected_review_promotes_to_b() {
254 let null = json!(null);
255 let rejected = json!({"status": "rejected"});
256 let events = vec![
257 ev("vev_001", "finding.asserted", "vf_x", &null),
258 ev("vev_002", "finding.reviewed", "vf_x", &rejected),
259 ];
260 assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
261 }
262
263 #[test]
264 fn finding_rejected_event_adds_refute() {
265 let null = json!(null);
266 let events = vec![
267 ev("vev_001", "finding.asserted", "vf_x", &null),
268 ev("vev_002", "finding.rejected", "vf_x", &null),
269 ];
270 assert_eq!(compute_belnap_status(events), BelnapStatus::Both);
271 }
272
273 #[test]
274 fn retraction_drops_all_prior_support_to_n() {
275 let null = json!(null);
276 let events = vec![
277 ev("vev_001", "finding.asserted", "vf_x", &null),
278 ev("vev_002", "finding.retracted", "vf_x", &null),
279 ];
280 assert_eq!(compute_belnap_status(events), BelnapStatus::None);
283 }
284
285 #[test]
286 fn retraction_drops_refute_too() {
287 let null = json!(null);
291 let rejected = json!({"status": "rejected"});
292 let events = vec![
293 ev("vev_001", "finding.asserted", "vf_x", &null),
294 ev("vev_002", "finding.reviewed", "vf_x", &rejected),
295 ev("vev_003", "finding.retracted", "vf_x", &null),
296 ];
297 assert_eq!(compute_belnap_status(events), BelnapStatus::None);
299 }
300
301 #[test]
302 fn needs_revision_keeps_t_not_b() {
303 let null = json!(null);
304 let nr = json!({"status": "needs_revision"});
305 let events = vec![
306 ev("vev_001", "finding.asserted", "vf_x", &null),
307 ev("vev_002", "finding.reviewed", "vf_x", &nr),
308 ];
309 assert_eq!(compute_belnap_status(events), BelnapStatus::True);
311 }
312
313 #[test]
314 fn unknown_review_status_is_ignored() {
315 let null = json!(null);
316 let weird = json!({"status": "potato"});
317 let events = vec![
318 ev("vev_001", "finding.asserted", "vf_x", &null),
319 ev("vev_002", "finding.reviewed", "vf_x", &weird),
320 ];
321 let sp = compute_status_provenance(events);
325 assert_eq!(sp.derive_status(), BelnapStatus::True);
326 assert_eq!(sp.support.term_count(), 1);
327 }
328
329 #[test]
330 fn support_polynomial_records_all_supporting_event_ids() {
331 let null = json!(null);
332 let accepted = json!({"status": "accepted"});
333 let events = vec![
334 ev("vev_001", "finding.asserted", "vf_x", &null),
335 ev("vev_002", "finding.reviewed", "vf_x", &accepted),
336 ev("vev_003", "finding.reviewed", "vf_x", &accepted),
337 ];
338 let sp = compute_status_provenance(events);
339 assert_eq!(sp.support.term_count(), 3);
340 let support_vars = sp.support.support();
341 assert!(support_vars.contains("vev_001"));
342 assert!(support_vars.contains("vev_002"));
343 assert!(support_vars.contains("vev_003"));
344 }
345
346 fn synthetic_project(
350 _finding_id: &str,
351 events: Vec<crate::events::StateEvent>,
352 ) -> crate::project::Project {
353 let mut p = crate::project::assemble("test-frontier", vec![], 0, 0, "test");
358 p.events.clear();
360 p.events = events;
361 p
362 }
363
364 fn synthetic_event(
365 id: &str,
366 kind: &str,
367 finding_id: &str,
368 status: Option<&str>,
369 ) -> crate::events::StateEvent {
370 use crate::events::{StateActor, StateEvent, StateTarget};
371 let payload = match status {
372 Some(s) => json!({"status": s}),
373 None => json!(null),
374 };
375 StateEvent {
376 schema: "vela.event.v0.1".into(),
377 id: id.to_string(),
378 kind: kind.to_string(),
379 target: StateTarget {
380 r#type: "finding".into(),
381 id: finding_id.to_string(),
382 },
383 actor: StateActor {
384 id: "reviewer:test".into(),
385 r#type: "human".into(),
386 },
387 timestamp: "2026-05-09T00:00:00Z".into(),
388 reason: "test".into(),
389 before_hash: String::new(),
390 after_hash: String::new(),
391 payload,
392 caveats: vec![],
393 signature: None,
394 schema_artifact_id: None,
395 }
396 }
397
398 #[test]
399 fn project_level_helper_filters_by_finding_id() {
400 let events = vec![
403 synthetic_event("vev_001", "finding.asserted", "vf_x", None),
404 synthetic_event("vev_002", "finding.asserted", "vf_y", None),
405 ];
406 let p = synthetic_project("vf_x", events);
407 let sp = status_provenance_for_finding(&p, "vf_x");
408 assert_eq!(sp.derive_status(), BelnapStatus::True);
409 assert_eq!(sp.support.term_count(), 1);
411 assert!(sp.support.support().contains("vev_001"));
412 assert!(!sp.support.support().contains("vev_002"));
413 }
414
415 #[test]
416 fn project_level_helper_handles_full_chain() {
417 let events = vec![
419 synthetic_event("vev_001", "finding.asserted", "vf_x", None),
420 synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("contested")),
421 ];
422 let p = synthetic_project("vf_x", events);
423 let belnap = belnap_status_for_finding(&p, "vf_x");
424 assert_eq!(belnap, BelnapStatus::Both);
425 }
426
427 #[test]
428 fn project_level_helper_with_no_events_yields_n() {
429 let p = synthetic_project("vf_x", vec![]);
430 assert_eq!(belnap_status_for_finding(&p, "vf_x"), BelnapStatus::None);
431 }
432
433 #[test]
434 fn unrelated_event_kinds_do_not_affect_status() {
435 let null = json!(null);
436 let events = vec![
437 ev("vev_001", "finding.asserted", "vf_x", &null),
438 ev("vev_002", "finding.entity_added", "vf_x", &null),
439 ev("vev_003", "finding.span_repaired", "vf_x", &null),
440 ];
441 let sp = compute_status_provenance(events);
442 assert_eq!(sp.derive_status(), BelnapStatus::True);
443 assert_eq!(sp.support.term_count(), 1);
444 }
445}