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