1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::ids::{FactEventId, FactId, SourceId, TimestampMillis};
7use crate::model::{
8 Authority, AuthorityLevel, FactEvent, FactEventKind, FactValue, Predicate, Subject, Ttl,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12pub enum FactLifecycle {
13 Active,
14 Contested,
15 Superseded,
16 Expired,
17 Retracted,
18}
19
20impl FactLifecycle {
21 #[must_use]
22 pub const fn is_terminal(self) -> bool {
23 matches!(self, Self::Superseded | Self::Expired | Self::Retracted)
24 }
25}
26
27#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
31pub struct FactState {
32 pub fact_id: FactId,
33 pub subject: Subject,
34 pub predicate: Predicate,
35 pub value: FactValue,
36 pub authority: Authority,
39 pub ttl: Ttl,
42 pub freshness_anchor: TimestampMillis,
45 #[serde(default)]
50 pub valid_from: Option<TimestampMillis>,
51 #[serde(default)]
56 pub valid_to: Option<TimestampMillis>,
57 pub lifecycle: FactLifecycle,
58 pub created_at: TimestampMillis,
59 pub updated_at: TimestampMillis,
60 pub last_event_id: FactEventId,
61 pub superseded_by: Option<FactId>,
62 pub contradicted_by: Vec<FactId>,
63 pub evidence_count: usize,
64 pub corroborating_sources: BTreeMap<SourceId, AuthorityLevel>,
70 #[serde(default)]
75 pub survived_challenges: BTreeMap<SourceId, AuthorityLevel>,
76}
77
78impl FactState {
79 #[must_use]
84 pub fn corroboration(&self) -> usize {
85 self.corroborating_sources.len()
86 }
87
88 #[must_use]
92 pub fn corroboration_at_or_above(&self, min: AuthorityLevel) -> usize {
93 self.corroborating_sources
94 .values()
95 .filter(|&&level| level >= min)
96 .count()
97 }
98
99 #[must_use]
103 pub fn survived_challenge_count(&self) -> usize {
104 self.survived_challenges.len()
105 }
106
107 #[must_use]
111 pub fn survived_challenges_at_or_above(&self, min: AuthorityLevel) -> usize {
112 self.survived_challenges
113 .values()
114 .filter(|&&level| level >= min)
115 .count()
116 }
117
118 #[must_use]
125 pub fn earned_entrenchment_at_or_above(&self, min: AuthorityLevel) -> usize {
126 self.corroboration_at_or_above(min) + self.survived_challenges_at_or_above(min)
127 }
128
129 #[must_use]
132 pub fn expires_at(&self) -> Option<TimestampMillis> {
133 match (self.ttl.expires_at(self.freshness_anchor), self.valid_to) {
134 (Some(ttl), Some(valid_to)) => Some(ttl.min(valid_to)),
135 (ttl, valid_to) => ttl.or(valid_to),
136 }
137 }
138
139 #[must_use]
143 pub fn is_not_yet_valid_at(&self, now: TimestampMillis) -> bool {
144 self.valid_from.is_some_and(|from| now < from)
145 }
146
147 #[must_use]
152 pub fn is_fresh_at(&self, now: TimestampMillis) -> bool {
153 !self.is_not_yet_valid_at(now) && !self.is_expired_at(now)
154 }
155
156 #[must_use]
161 pub fn is_expired_at(&self, now: TimestampMillis) -> bool {
162 self.expires_at()
163 .is_some_and(|expires_at| expires_at <= now)
164 }
165}
166
167pub fn apply_event(
168 current: Option<FactState>,
169 event: &FactEvent,
170) -> Result<FactState, TransitionError> {
171 event.validate().map_err(TransitionError::InvalidEvent)?;
172
173 match current {
174 None => apply_initial_event(event),
175 Some(state) => apply_next_event(state, event),
176 }
177}
178
179fn apply_initial_event(event: &FactEvent) -> Result<FactState, TransitionError> {
180 if !matches!(event.kind, FactEventKind::Asserted) {
181 return Err(TransitionError::MissingInitialAssertion);
182 }
183
184 let value = event
185 .value
186 .clone()
187 .ok_or(TransitionError::MissingInitialAssertion)?;
188
189 let freshness_anchor = event
190 .valid_from
191 .or(event.observed_at)
192 .unwrap_or(event.provenance.recorded_at);
193
194 Ok(FactState {
195 fact_id: event.fact_id.clone(),
196 subject: event.subject.clone(),
197 predicate: event.predicate.clone(),
198 value,
199 authority: event.authority.clone(),
200 ttl: event.ttl.clone(),
201 freshness_anchor,
202 valid_from: event.valid_from,
203 valid_to: event.valid_to,
204 lifecycle: FactLifecycle::Active,
205 created_at: event.provenance.recorded_at,
206 updated_at: event.provenance.recorded_at,
207 last_event_id: event.event_id.clone(),
208 superseded_by: None,
209 contradicted_by: Vec::new(),
210 evidence_count: event.evidence.len(),
211 corroborating_sources: BTreeMap::from([(
212 event.provenance.source.clone(),
213 event.authority.level,
214 )]),
215 survived_challenges: BTreeMap::new(),
216 })
217}
218
219fn apply_next_event(mut state: FactState, event: &FactEvent) -> Result<FactState, TransitionError> {
220 if state.fact_id != event.fact_id {
221 return Err(TransitionError::FactIdMismatch);
222 }
223
224 if state.subject != event.subject || state.predicate != event.predicate {
225 return Err(TransitionError::FactShapeMismatch);
226 }
227
228 if state.lifecycle.is_terminal()
229 && !matches!(
230 event.kind,
231 FactEventKind::Retrieved { .. } | FactEventKind::UsedInDecision { .. }
232 )
233 {
234 return Err(TransitionError::TerminalStateMutation(state.lifecycle));
235 }
236
237 match &event.kind {
238 FactEventKind::Asserted => return Err(TransitionError::DuplicateAssertion),
239 FactEventKind::Reinforced { .. } => {
240 if let Some(value) = &event.value
241 && value != &state.value
242 {
243 return Err(TransitionError::ReinforcementValueMismatch);
244 }
245 state.evidence_count += event.evidence.len();
246 state
249 .corroborating_sources
250 .entry(event.provenance.source.clone())
251 .and_modify(|level| *level = (*level).max(event.authority.level))
252 .or_insert(event.authority.level);
253 }
254 FactEventKind::Contradicted { by, .. } => {
255 if state.authority.level == AuthorityLevel::Canonical {
260 return Err(TransitionError::CanonicalContradiction);
261 }
262 state.lifecycle = FactLifecycle::Contested;
263 if !state.contradicted_by.contains(by) {
264 state.contradicted_by.push(by.clone());
265 }
266 }
267 FactEventKind::Superseded { by, .. } => {
268 if event.authority.level < state.authority.level {
273 return Err(TransitionError::InsufficientAuthority {
274 incumbent: state.authority.level,
275 challenger: event.authority.level,
276 });
277 }
278 state.lifecycle = FactLifecycle::Superseded;
279 state.superseded_by = Some(by.clone());
280 }
281 FactEventKind::Expired { .. } => {
282 if event.authority.level < state.authority.level {
286 return Err(TransitionError::InsufficientAuthority {
287 incumbent: state.authority.level,
288 challenger: event.authority.level,
289 });
290 }
291 state.lifecycle = FactLifecycle::Expired;
292 }
293 FactEventKind::Retracted { .. } => {
294 if event.authority.level < state.authority.level {
302 return Err(TransitionError::InsufficientAuthority {
303 incumbent: state.authority.level,
304 challenger: event.authority.level,
305 });
306 }
307 state.lifecycle = FactLifecycle::Retracted;
308 }
309 FactEventKind::Retrieved { .. } | FactEventKind::UsedInDecision { .. } => {}
310 FactEventKind::ChallengeRejected { .. } => {
311 state
317 .survived_challenges
318 .entry(event.provenance.source.clone())
319 .and_modify(|level| *level = (*level).max(event.authority.level))
320 .or_insert(event.authority.level);
321 }
322 }
323
324 state.updated_at = event.provenance.recorded_at;
325 state.last_event_id = event.event_id.clone();
326 Ok(state)
327}
328
329#[derive(Clone, Debug, Eq, PartialEq)]
330pub enum TransitionError {
331 InvalidEvent(crate::model::ValidationError),
332 MissingInitialAssertion,
333 DuplicateAssertion,
334 FactIdMismatch,
335 FactShapeMismatch,
336 ReinforcementValueMismatch,
337 TerminalStateMutation(FactLifecycle),
338 InsufficientAuthority {
345 incumbent: AuthorityLevel,
346 challenger: AuthorityLevel,
347 },
348 CanonicalContradiction,
352}
353
354impl fmt::Display for TransitionError {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 match self {
357 Self::InvalidEvent(error) => write!(f, "invalid event: {error}"),
358 Self::MissingInitialAssertion => {
359 f.write_str("fact stream must start with fact.asserted")
360 }
361 Self::DuplicateAssertion => {
362 f.write_str("fact stream cannot assert the same fact twice")
363 }
364 Self::FactIdMismatch => f.write_str("event fact_id does not match current state"),
365 Self::FactShapeMismatch => {
366 f.write_str("event subject or predicate does not match current state")
367 }
368 Self::ReinforcementValueMismatch => {
369 f.write_str("fact.reinforced cannot change the fact value")
370 }
371 Self::TerminalStateMutation(state) => {
372 write!(f, "cannot mutate terminal fact state {state:?}")
373 }
374 Self::InsufficientAuthority {
375 incumbent,
376 challenger,
377 } => write!(
378 f,
379 "insufficient authority: {challenger} may not override or remove an incumbent of {incumbent}"
380 ),
381 Self::CanonicalContradiction => {
382 f.write_str("a canonical fact cannot be contradicted; supersede it with equal authority instead")
383 }
384 }
385 }
386}
387
388impl std::error::Error for TransitionError {}
389
390#[cfg(test)]
391mod tests {
392 use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
393 use crate::model::{
394 Authority, AuthorityLevel, Confidence, ContradictionBasis, Evidence, EvidenceKind,
395 ExpirationReason, FactEvent, FactEventKind, FactValue, Predicate, Provenance,
396 RetractionReason, Subject, SupersessionReason, Ttl,
397 };
398
399 use super::{FactLifecycle, TransitionError, apply_event};
400
401 const ALL_LEVELS: [AuthorityLevel; 5] = [
402 AuthorityLevel::Unknown,
403 AuthorityLevel::Low,
404 AuthorityLevel::Medium,
405 AuthorityLevel::High,
406 AuthorityLevel::Canonical,
407 ];
408
409 fn with_authority(mut event: FactEvent, level: AuthorityLevel) -> FactEvent {
410 event.authority = Authority {
411 level,
412 issuer: None,
413 scope: None,
414 };
415 event
416 }
417
418 fn asserted(level: AuthorityLevel) -> FactEvent {
419 with_authority(
420 base_event(
421 FactEventKind::Asserted,
422 "event:1",
423 Some(FactValue::Text("postgres".to_string())),
424 ),
425 level,
426 )
427 }
428
429 fn supersede(event_id: &str, level: AuthorityLevel) -> FactEvent {
430 with_authority(
431 base_event(
432 FactEventKind::Superseded {
433 by: fact_id("fact:2"),
434 reason: SupersessionReason::NewerObservation,
435 },
436 event_id,
437 None,
438 ),
439 level,
440 )
441 }
442
443 fn retract(event_id: &str, level: AuthorityLevel) -> FactEvent {
444 with_authority(
445 base_event(
446 FactEventKind::Retracted {
447 reason: RetractionReason::UserDeleted,
448 },
449 event_id,
450 None,
451 ),
452 level,
453 )
454 }
455
456 fn expire(event_id: &str, level: AuthorityLevel) -> FactEvent {
457 with_authority(
458 base_event(
459 FactEventKind::Expired {
460 reason: ExpirationReason::PolicyRetention,
461 },
462 event_id,
463 None,
464 ),
465 level,
466 )
467 }
468
469 fn fact_id(value: &str) -> FactId {
470 FactId::new(value).expect("valid fact id")
471 }
472
473 fn base_event(kind: FactEventKind, event_id: &str, value: Option<FactValue>) -> FactEvent {
474 FactEvent {
475 event_id: FactEventId::new(event_id).expect("valid event id"),
476 fact_id: fact_id("fact:1"),
477 kind,
478 subject: Subject::new("repo", "dent8").expect("valid subject"),
479 predicate: Predicate::new("uses_database").expect("valid predicate"),
480 value,
481 confidence: Confidence::from_millis(900).expect("valid confidence"),
482 authority: Authority::unknown(),
483 ttl: Ttl::Never,
484 provenance: Provenance {
485 source: SourceId::new("source:test").expect("valid source"),
486 actor: ActorId::new("actor:test").expect("valid actor"),
487 tool: Some("unit-test".to_string()),
488 run_id: None,
489 input_digest: None,
490 recorded_at: TimestampMillis::from_unix_millis(1),
491 attestation: None,
492 },
493 evidence: vec![Evidence {
494 id: EvidenceId::new("evidence:1").expect("valid evidence id"),
495 kind: EvidenceKind::UserStatement,
496 locator: "test".to_string(),
497 digest: None,
498 summary: Some("test evidence".to_string()),
499 }],
500 observed_at: None,
501 valid_from: None,
502 valid_to: None,
503 }
504 }
505
506 fn challenge_rejected(event_id: &str, source: &str, level: AuthorityLevel) -> FactEvent {
507 let mut event = with_authority(
508 base_event(
509 FactEventKind::ChallengeRejected {
510 challenge: crate::model::ChallengeKind::Supersession,
511 by: Some(fact_id("fact:2")),
512 rejection: crate::model::ChallengeRejection::InsufficientAuthority,
513 },
514 event_id,
515 None,
516 ),
517 level,
518 );
519 event.provenance.source = SourceId::new(source).expect("valid source");
520 event
521 }
522
523 #[test]
524 fn surviving_challenges_accumulates_challengers_at_their_strongest() {
525 let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
526 let state = apply_event(
528 Some(state),
529 &challenge_rejected("event:2", "source:attacker", AuthorityLevel::Low),
530 )
531 .expect("recorded");
532 let state = apply_event(
533 Some(state),
534 &challenge_rejected("event:3", "source:attacker", AuthorityLevel::Medium),
535 )
536 .expect("recorded");
537 let state = apply_event(
538 Some(state),
539 &challenge_rejected("event:4", "source:other", AuthorityLevel::Low),
540 )
541 .expect("recorded");
542
543 assert_eq!(state.lifecycle, FactLifecycle::Active);
545 assert_eq!(state.authority.level, AuthorityLevel::High);
546 assert_eq!(state.survived_challenge_count(), 2);
548 assert_eq!(
549 state.survived_challenges_at_or_above(AuthorityLevel::Medium),
550 1,
551 "a Sybil flood of low-authority challenges cannot simulate surviving strong ones"
552 );
553 assert_eq!(
554 state.survived_challenges_at_or_above(AuthorityLevel::Low),
555 2
556 );
557
558 assert_eq!(
561 state.earned_entrenchment_at_or_above(AuthorityLevel::Low),
562 state.corroboration_at_or_above(AuthorityLevel::Low)
563 + state.survived_challenges_at_or_above(AuthorityLevel::Low),
564 );
565 assert_eq!(
566 state.earned_entrenchment_at_or_above(AuthorityLevel::Low),
567 3, );
569 assert_eq!(
570 state.earned_entrenchment_at_or_above(AuthorityLevel::Medium),
571 2, );
573 assert_eq!(
574 state.earned_entrenchment_at_or_above(AuthorityLevel::Canonical),
575 0, );
577 }
578
579 #[test]
580 fn a_challenge_record_is_not_an_initial_event_and_not_a_terminal_mutation() {
581 assert!(matches!(
583 apply_event(
584 None,
585 &challenge_rejected("event:1", "source:attacker", AuthorityLevel::Low)
586 ),
587 Err(TransitionError::MissingInitialAssertion)
588 ));
589 let state = apply_event(None, &asserted(AuthorityLevel::Low)).expect("asserted");
592 let state = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Low))
593 .expect("superseded");
594 assert!(matches!(
595 apply_event(
596 Some(state),
597 &challenge_rejected("event:3", "source:attacker", AuthorityLevel::Low)
598 ),
599 Err(TransitionError::TerminalStateMutation(
600 FactLifecycle::Superseded
601 ))
602 ));
603 }
604
605 #[test]
606 fn fact_state_without_survived_challenges_field_still_deserializes() {
607 let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
609 let mut json = serde_json::to_value(&state).expect("serialize");
610 json.as_object_mut()
611 .expect("object")
612 .remove("survived_challenges")
613 .expect("field present in new serialization");
614 let old: super::FactState = serde_json::from_value(json).expect("old shape deserializes");
615 assert_eq!(old.survived_challenge_count(), 0);
616 }
617
618 #[test]
619 fn valid_to_bounds_freshness_like_an_elapsed_ttl() {
620 let mut event = asserted(AuthorityLevel::High);
621 event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
622 event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
623 let state = apply_event(None, &event).expect("asserted");
624
625 assert_eq!(
627 state.expires_at(),
628 Some(TimestampMillis::from_unix_millis(2_000))
629 );
630 assert!(!state.is_expired_at(TimestampMillis::from_unix_millis(1_999)));
631 assert!(state.is_expired_at(TimestampMillis::from_unix_millis(2_000)));
632
633 let mut event = asserted(AuthorityLevel::High);
635 event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
636 event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
637 event.ttl = crate::model::Ttl::DurationMillis(500);
638 let state = apply_event(None, &event).expect("asserted");
639 assert_eq!(
640 state.expires_at(),
641 Some(TimestampMillis::from_unix_millis(1_500)),
642 "a shorter TTL beats a later valid_to"
643 );
644 let mut event = asserted(AuthorityLevel::High);
645 event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
646 event.valid_to = Some(TimestampMillis::from_unix_millis(1_200));
647 event.ttl = crate::model::Ttl::DurationMillis(500);
648 let state = apply_event(None, &event).expect("asserted");
649 assert_eq!(
650 state.expires_at(),
651 Some(TimestampMillis::from_unix_millis(1_200)),
652 "an earlier valid_to beats a longer TTL"
653 );
654 }
655
656 #[test]
657 fn a_future_valid_from_reads_not_yet_valid_not_fresh() {
658 let mut event = asserted(AuthorityLevel::High);
659 event.valid_from = Some(TimestampMillis::from_unix_millis(2_000));
660 let state = apply_event(None, &event).expect("asserted");
661
662 assert!(state.is_not_yet_valid_at(TimestampMillis::from_unix_millis(1_000)));
664 assert!(!state.is_fresh_at(TimestampMillis::from_unix_millis(1_000)));
665 assert!(!state.is_expired_at(TimestampMillis::from_unix_millis(1_000)));
667 assert!(!state.is_not_yet_valid_at(TimestampMillis::from_unix_millis(2_000)));
669 assert!(state.is_fresh_at(TimestampMillis::from_unix_millis(2_000)));
670
671 let plain = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
673 assert!(!plain.is_not_yet_valid_at(TimestampMillis::from_unix_millis(0)));
674 assert!(plain.is_fresh_at(TimestampMillis::from_unix_millis(0)));
675 }
676
677 #[test]
678 fn an_empty_or_inverted_validity_interval_is_rejected() {
679 let mut event = asserted(AuthorityLevel::High);
680 event.valid_from = Some(TimestampMillis::from_unix_millis(2_000));
681 event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
682 assert!(matches!(
683 apply_event(None, &event),
684 Err(TransitionError::InvalidEvent(
685 crate::model::ValidationError::InvalidValidityInterval
686 ))
687 ));
688 }
689
690 #[test]
691 fn assertion_creates_active_state() {
692 let event = base_event(
693 FactEventKind::Asserted,
694 "event:1",
695 Some(FactValue::Text("postgres".to_string())),
696 );
697
698 let state = apply_event(None, &event).expect("assertion applies");
699
700 assert_eq!(state.lifecycle, FactLifecycle::Active);
701 assert_eq!(state.evidence_count, 1);
702 }
703
704 #[test]
705 fn duplicate_assertion_is_rejected() {
706 let event = base_event(
707 FactEventKind::Asserted,
708 "event:1",
709 Some(FactValue::Text("postgres".to_string())),
710 );
711 let state = apply_event(None, &event).expect("assertion applies");
712
713 let error = apply_event(Some(state), &event).expect_err("duplicate rejected");
714
715 assert_eq!(error, TransitionError::DuplicateAssertion);
716 }
717
718 #[test]
719 fn contradiction_marks_fact_contested() {
720 let asserted = base_event(
721 FactEventKind::Asserted,
722 "event:1",
723 Some(FactValue::Text("postgres".to_string())),
724 );
725 let state = apply_event(None, &asserted).expect("assertion applies");
726 let contradicted = base_event(
727 FactEventKind::Contradicted {
728 by: fact_id("fact:2"),
729 basis: ContradictionBasis::SamePredicateDifferentValue,
730 },
731 "event:2",
732 None,
733 );
734
735 let state = apply_event(Some(state), &contradicted).expect("contradiction applies");
736
737 assert_eq!(state.lifecycle, FactLifecycle::Contested);
738 assert_eq!(state.contradicted_by, vec![fact_id("fact:2")]);
739 }
740
741 #[test]
742 fn terminal_state_rejects_lifecycle_mutation() {
743 let asserted = base_event(
744 FactEventKind::Asserted,
745 "event:1",
746 Some(FactValue::Text("postgres".to_string())),
747 );
748 let state = apply_event(None, &asserted).expect("assertion applies");
749 let superseded = base_event(
750 FactEventKind::Superseded {
751 by: fact_id("fact:2"),
752 reason: SupersessionReason::NewerObservation,
753 },
754 "event:2",
755 None,
756 );
757 let state = apply_event(Some(state), &superseded).expect("supersession applies");
758 let reinforced = base_event(
759 FactEventKind::Reinforced {
760 by: fact_id("fact:3"),
761 },
762 "event:3",
763 Some(FactValue::Text("postgres".to_string())),
764 );
765
766 let error = apply_event(Some(state), &reinforced).expect_err("terminal mutation rejected");
767
768 assert_eq!(
769 error,
770 TransitionError::TerminalStateMutation(FactLifecycle::Superseded)
771 );
772 }
773
774 #[test]
775 fn retrieval_does_not_change_lifecycle() {
776 let asserted = base_event(
777 FactEventKind::Asserted,
778 "event:1",
779 Some(FactValue::Text("postgres".to_string())),
780 );
781 let state = apply_event(None, &asserted).expect("assertion applies");
782 let retrieved = base_event(
783 FactEventKind::Retrieved {
784 purpose: "context".to_string(),
785 },
786 "event:2",
787 None,
788 );
789
790 let state = apply_event(Some(state), &retrieved).expect("retrieval applies");
791
792 assert_eq!(state.lifecycle, FactLifecycle::Active);
793 }
794
795 #[test]
796 fn lower_authority_supersession_is_rejected() {
797 let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
798
799 let error = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Low))
800 .expect_err("low-authority supersession rejected");
801
802 assert_eq!(
803 error,
804 TransitionError::InsufficientAuthority {
805 incumbent: AuthorityLevel::High,
806 challenger: AuthorityLevel::Low,
807 }
808 );
809 }
810
811 #[test]
812 fn equal_authority_supersession_succeeds() {
813 let state =
814 apply_event(None, &asserted(AuthorityLevel::Medium)).expect("assertion applies");
815
816 let state = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Medium))
817 .expect("equal-authority supersession applies");
818
819 assert_eq!(state.lifecycle, FactLifecycle::Superseded);
820 }
821
822 #[test]
823 fn higher_authority_supersession_succeeds() {
824 let state = apply_event(None, &asserted(AuthorityLevel::Low)).expect("assertion applies");
825
826 let state = apply_event(
827 Some(state),
828 &supersede("event:2", AuthorityLevel::Canonical),
829 )
830 .expect("higher-authority supersession applies");
831
832 assert_eq!(state.lifecycle, FactLifecycle::Superseded);
833 }
834
835 #[test]
836 fn contradicting_a_canonical_fact_hard_alarms() {
837 let state =
838 apply_event(None, &asserted(AuthorityLevel::Canonical)).expect("assertion applies");
839 let contradicted = base_event(
840 FactEventKind::Contradicted {
841 by: fact_id("fact:2"),
842 basis: ContradictionBasis::SamePredicateDifferentValue,
843 },
844 "event:2",
845 None,
846 );
847
848 let error = apply_event(Some(state), &contradicted)
849 .expect_err("canonical contradiction hard-alarms");
850
851 assert_eq!(error, TransitionError::CanonicalContradiction);
852 }
853
854 #[test]
855 fn contradicting_a_non_canonical_fact_still_contests() {
856 let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
857 let contradicted = base_event(
858 FactEventKind::Contradicted {
859 by: fact_id("fact:2"),
860 basis: ContradictionBasis::SamePredicateDifferentValue,
861 },
862 "event:2",
863 None,
864 );
865
866 let state =
867 apply_event(Some(state), &contradicted).expect("non-canonical contradiction applies");
868
869 assert_eq!(state.lifecycle, FactLifecycle::Contested);
870 }
871
872 #[test]
879 fn authority_monotone_supersession_and_non_resurrection() {
880 for incumbent in ALL_LEVELS {
881 for challenger in ALL_LEVELS {
882 let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
883 let result = apply_event(Some(state), &supersede("event:2", challenger));
884
885 if challenger < incumbent {
886 assert_eq!(
887 result,
888 Err(TransitionError::InsufficientAuthority {
889 incumbent,
890 challenger,
891 }),
892 "challenger {challenger:?} should not supersede incumbent {incumbent:?}",
893 );
894 continue;
895 }
896
897 let superseded = result.expect("non-under-ranking supersession applies");
898 assert!(
899 superseded.lifecycle.is_terminal(),
900 "supersession should make the fact terminal",
901 );
902
903 let resurrect = supersede("event:3", AuthorityLevel::Canonical);
906 let error = apply_event(Some(superseded), &resurrect)
907 .expect_err("terminal fact cannot be resurrected");
908 assert_eq!(
909 error,
910 TransitionError::TerminalStateMutation(FactLifecycle::Superseded)
911 );
912 }
913 }
914 }
915
916 #[test]
922 fn authority_monotone_retraction_and_non_resurrection() {
923 for incumbent in ALL_LEVELS {
924 for challenger in ALL_LEVELS {
925 let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
926 let result = apply_event(Some(state), &retract("event:2", challenger));
927
928 if challenger < incumbent {
929 assert_eq!(
930 result,
931 Err(TransitionError::InsufficientAuthority {
932 incumbent,
933 challenger,
934 }),
935 "retractor {challenger:?} should not retract incumbent {incumbent:?}",
936 );
937 continue;
938 }
939
940 let retracted = result.expect("non-under-ranking retraction applies");
941 assert_eq!(retracted.lifecycle, FactLifecycle::Retracted);
942 assert!(retracted.lifecycle.is_terminal());
943
944 let resurrect = supersede("event:3", AuthorityLevel::Canonical);
945 let error = apply_event(Some(retracted), &resurrect)
946 .expect_err("terminal fact cannot be resurrected");
947 assert_eq!(
948 error,
949 TransitionError::TerminalStateMutation(FactLifecycle::Retracted)
950 );
951 }
952 }
953 }
954
955 #[test]
959 fn authority_monotone_expiration_and_non_resurrection() {
960 for incumbent in ALL_LEVELS {
961 for challenger in ALL_LEVELS {
962 let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
963 let result = apply_event(Some(state), &expire("event:2", challenger));
964
965 if challenger < incumbent {
966 assert_eq!(
967 result,
968 Err(TransitionError::InsufficientAuthority {
969 incumbent,
970 challenger,
971 }),
972 "expirer {challenger:?} should not expire incumbent {incumbent:?}",
973 );
974 continue;
975 }
976
977 let expired = result.expect("non-under-ranking expiration applies");
978 assert_eq!(expired.lifecycle, FactLifecycle::Expired);
979 assert!(expired.lifecycle.is_terminal());
980
981 let resurrect = supersede("event:3", AuthorityLevel::Canonical);
982 let error = apply_event(Some(expired), &resurrect)
983 .expect_err("terminal fact cannot be resurrected");
984 assert_eq!(
985 error,
986 TransitionError::TerminalStateMutation(FactLifecycle::Expired)
987 );
988 }
989 }
990 }
991}
992
993#[cfg(kani)]
999mod proofs {
1000 use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
1001 use crate::model::{
1002 Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, ExpirationReason, FactEvent,
1003 FactEventKind, FactValue, Predicate, Provenance, Subject, SupersessionReason, Ttl,
1004 };
1005
1006 use super::apply_event;
1007
1008 fn level_from(n: u8) -> AuthorityLevel {
1009 match n {
1010 0 => AuthorityLevel::Unknown,
1011 1 => AuthorityLevel::Low,
1012 2 => AuthorityLevel::Medium,
1013 3 => AuthorityLevel::High,
1014 _ => AuthorityLevel::Canonical,
1015 }
1016 }
1017
1018 fn event(
1019 kind: FactEventKind,
1020 event_id: &str,
1021 value: Option<FactValue>,
1022 level: AuthorityLevel,
1023 ) -> FactEvent {
1024 FactEvent {
1025 event_id: FactEventId::new(event_id).unwrap(),
1026 fact_id: FactId::new("fact:1").unwrap(),
1027 kind,
1028 subject: Subject::new("repo", "dent8").unwrap(),
1029 predicate: Predicate::new("uses_database").unwrap(),
1030 value,
1031 confidence: Confidence::from_millis(900).unwrap(),
1032 authority: Authority {
1033 level,
1034 issuer: None,
1035 scope: None,
1036 },
1037 ttl: Ttl::Never,
1038 provenance: Provenance {
1039 source: SourceId::new("source:test").unwrap(),
1040 actor: ActorId::new("actor:test").unwrap(),
1041 tool: None,
1042 run_id: None,
1043 input_digest: None,
1044 recorded_at: TimestampMillis::from_unix_millis(1),
1045 attestation: None,
1046 },
1047 evidence: vec![Evidence {
1048 id: EvidenceId::new("evidence:1").unwrap(),
1049 kind: EvidenceKind::UserStatement,
1050 locator: "x".to_string(),
1051 digest: None,
1052 summary: None,
1053 }],
1054 observed_at: None,
1055 valid_from: None,
1056 valid_to: None,
1057 }
1058 }
1059
1060 #[kani::proof]
1061 fn supersession_is_authority_monotone_and_non_resurrecting() {
1062 let a: u8 = kani::any();
1063 let b: u8 = kani::any();
1064 kani::assume(a < 5);
1065 kani::assume(b < 5);
1066 let incumbent = level_from(a);
1067 let challenger = level_from(b);
1068
1069 let asserted = event(
1070 FactEventKind::Asserted,
1071 "event:1",
1072 Some(FactValue::Text("postgres".to_string())),
1073 incumbent,
1074 );
1075 let state = apply_event(None, &asserted).unwrap();
1076
1077 let superseded = event(
1078 FactEventKind::Superseded {
1079 by: FactId::new("fact:2").unwrap(),
1080 reason: SupersessionReason::NewerObservation,
1081 },
1082 "event:2",
1083 None,
1084 challenger,
1085 );
1086
1087 match apply_event(Some(state), &superseded) {
1088 Ok(next) => {
1089 assert!(challenger >= incumbent);
1092 assert!(next.lifecycle.is_terminal());
1093
1094 let resurrect = event(
1096 FactEventKind::Superseded {
1097 by: FactId::new("fact:3").unwrap(),
1098 reason: SupersessionReason::NewerObservation,
1099 },
1100 "event:3",
1101 None,
1102 AuthorityLevel::Canonical,
1103 );
1104 assert!(apply_event(Some(next), &resurrect).is_err());
1105 }
1106 Err(_) => {
1107 assert!(challenger < incumbent);
1109 }
1110 }
1111 }
1112
1113 #[kani::proof]
1114 fn retraction_is_authority_monotone_and_non_resurrecting() {
1115 use crate::model::RetractionReason;
1116
1117 let a: u8 = kani::any();
1118 let b: u8 = kani::any();
1119 kani::assume(a < 5);
1120 kani::assume(b < 5);
1121 let incumbent = level_from(a);
1122 let challenger = level_from(b);
1123
1124 let asserted = event(
1125 FactEventKind::Asserted,
1126 "event:1",
1127 Some(FactValue::Text("postgres".to_string())),
1128 incumbent,
1129 );
1130 let state = apply_event(None, &asserted).unwrap();
1131
1132 let retracted = event(
1133 FactEventKind::Retracted {
1134 reason: RetractionReason::UserDeleted,
1135 },
1136 "event:2",
1137 None,
1138 challenger,
1139 );
1140
1141 match apply_event(Some(state), &retracted) {
1142 Ok(next) => {
1143 assert!(challenger >= incumbent);
1144 assert!(next.lifecycle.is_terminal());
1145
1146 let resurrect = event(
1147 FactEventKind::Superseded {
1148 by: FactId::new("fact:3").unwrap(),
1149 reason: SupersessionReason::NewerObservation,
1150 },
1151 "event:3",
1152 None,
1153 AuthorityLevel::Canonical,
1154 );
1155 assert!(apply_event(Some(next), &resurrect).is_err());
1156 }
1157 Err(_) => {
1158 assert!(challenger < incumbent);
1159 }
1160 }
1161 }
1162
1163 #[kani::proof]
1164 fn expiration_is_authority_monotone_and_non_resurrecting() {
1165 let a: u8 = kani::any();
1166 let b: u8 = kani::any();
1167 kani::assume(a < 5);
1168 kani::assume(b < 5);
1169 let incumbent = level_from(a);
1170 let challenger = level_from(b);
1171
1172 let asserted = event(
1173 FactEventKind::Asserted,
1174 "event:1",
1175 Some(FactValue::Text("postgres".to_string())),
1176 incumbent,
1177 );
1178 let state = apply_event(None, &asserted).unwrap();
1179
1180 let expired = event(
1181 FactEventKind::Expired {
1182 reason: ExpirationReason::PolicyRetention,
1183 },
1184 "event:2",
1185 None,
1186 challenger,
1187 );
1188
1189 match apply_event(Some(state), &expired) {
1190 Ok(next) => {
1191 assert!(challenger >= incumbent);
1192 assert!(next.lifecycle.is_terminal());
1193
1194 let resurrect = event(
1195 FactEventKind::Superseded {
1196 by: FactId::new("fact:3").unwrap(),
1197 reason: SupersessionReason::NewerObservation,
1198 },
1199 "event:3",
1200 None,
1201 AuthorityLevel::Canonical,
1202 );
1203 assert!(apply_event(Some(next), &resurrect).is_err());
1204 }
1205 Err(_) => {
1206 assert!(challenger < incumbent);
1207 }
1208 }
1209 }
1210}