Skip to main content

dent8_core/
state.rs

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/// The current projected state of a claim — the fold of its event stream. Serializable so
28/// a backend may **materialize** it (cache it as a derived row) or transmit it; it remains a
29/// pure projection of the log, never an independent source of truth.
30#[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    /// Entrenchment of the incumbent claim, captured at assertion. Drives
37    /// authority-weighted supersession arbitration; see `docs/belief-revision.md`.
38    pub authority: Authority,
39    /// TTL captured at assertion, used by [`ClaimState::is_expired_at`] for
40    /// read-time freshness evaluation.
41    pub ttl: Ttl,
42    /// Valid-time anchor for TTL freshness: `valid_from`, else `observed_at`, else
43    /// the assertion's `recorded_at`.
44    pub freshness_anchor: TimestampMillis,
45    pub lifecycle: ClaimLifecycle,
46    pub created_at: TimestampMillis,
47    pub updated_at: TimestampMillis,
48    pub last_event_id: ClaimEventId,
49    pub superseded_by: Option<ClaimId>,
50    pub contradicted_by: Vec<ClaimId>,
51    pub evidence_count: usize,
52    /// The distinct provenance sources that have *asserted or reinforced this exact
53    /// value*, each mapped to the highest authority it backed at. This measures
54    /// same-value corroboration only — surviving a contradiction or a rejected
55    /// supersession is deliberately *not* counted here (that would require recording
56    /// rejected attempts; see `docs/research/novelty.md` rank 3). The asserter is the
57    /// first entry.
58    pub corroborating_sources: BTreeMap<SourceId, AuthorityLevel>,
59}
60
61impl ClaimState {
62    /// Raw earned-entrenchment degree: the number of distinct sources backing this
63    /// value. **Sybil-inflatable** on its own (an attacker minting many sources raises
64    /// it), so security decisions should use [`ClaimState::corroboration_at_or_above`]
65    /// to count only sufficiently-authoritative backers.
66    #[must_use]
67    pub fn corroboration(&self) -> usize {
68        self.corroborating_sources.len()
69    }
70
71    /// Authority-weighted corroboration: the number of distinct backing sources whose
72    /// authority is at least `min`. This is the Sybil-resistant entrenchment signal —
73    /// minting low-authority sources cannot raise the count measured at a higher floor.
74    #[must_use]
75    pub fn corroboration_at_or_above(&self, min: AuthorityLevel) -> usize {
76        self.corroborating_sources
77            .values()
78            .filter(|&&level| level >= min)
79            .count()
80    }
81
82    /// When this claim's TTL elapses relative to its freshness anchor, if ever.
83    #[must_use]
84    pub fn expires_at(&self) -> Option<TimestampMillis> {
85        self.ttl.expires_at(self.freshness_anchor)
86    }
87
88    /// Whether the claim's TTL has elapsed at `now`. A claim with `Ttl::Never` is
89    /// never expired. This is the read-time freshness predicate; it does not consult
90    /// lifecycle (a `superseded` claim can still be "unexpired" by TTL).
91    #[must_use]
92    pub fn is_expired_at(&self, now: TimestampMillis) -> bool {
93        self.ttl.is_expired_at(self.freshness_anchor, now)
94    }
95}
96
97pub fn apply_event(
98    current: Option<ClaimState>,
99    event: &ClaimEvent,
100) -> Result<ClaimState, TransitionError> {
101    event.validate().map_err(TransitionError::InvalidEvent)?;
102
103    match current {
104        None => apply_initial_event(event),
105        Some(state) => apply_next_event(state, event),
106    }
107}
108
109fn apply_initial_event(event: &ClaimEvent) -> Result<ClaimState, TransitionError> {
110    if !matches!(event.kind, ClaimEventKind::Asserted) {
111        return Err(TransitionError::MissingInitialAssertion);
112    }
113
114    let value = event
115        .value
116        .clone()
117        .ok_or(TransitionError::MissingInitialAssertion)?;
118
119    let freshness_anchor = event
120        .valid_from
121        .or(event.observed_at)
122        .unwrap_or(event.provenance.recorded_at);
123
124    Ok(ClaimState {
125        claim_id: event.claim_id.clone(),
126        subject: event.subject.clone(),
127        predicate: event.predicate.clone(),
128        value,
129        authority: event.authority.clone(),
130        ttl: event.ttl.clone(),
131        freshness_anchor,
132        lifecycle: ClaimLifecycle::Active,
133        created_at: event.provenance.recorded_at,
134        updated_at: event.provenance.recorded_at,
135        last_event_id: event.event_id.clone(),
136        superseded_by: None,
137        contradicted_by: Vec::new(),
138        evidence_count: event.evidence.len(),
139        corroborating_sources: BTreeMap::from([(
140            event.provenance.source.clone(),
141            event.authority.level,
142        )]),
143    })
144}
145
146fn apply_next_event(
147    mut state: ClaimState,
148    event: &ClaimEvent,
149) -> Result<ClaimState, TransitionError> {
150    if state.claim_id != event.claim_id {
151        return Err(TransitionError::ClaimIdMismatch);
152    }
153
154    if state.subject != event.subject || state.predicate != event.predicate {
155        return Err(TransitionError::ClaimShapeMismatch);
156    }
157
158    if state.lifecycle.is_terminal()
159        && !matches!(
160            event.kind,
161            ClaimEventKind::Retrieved { .. } | ClaimEventKind::UsedInDecision { .. }
162        )
163    {
164        return Err(TransitionError::TerminalStateMutation(state.lifecycle));
165    }
166
167    match &event.kind {
168        ClaimEventKind::Asserted => return Err(TransitionError::DuplicateAssertion),
169        ClaimEventKind::Reinforced { .. } => {
170            if let Some(value) = &event.value
171                && value != &state.value
172            {
173                return Err(TransitionError::ReinforcementValueMismatch);
174            }
175            state.evidence_count += event.evidence.len();
176            // A distinct reinforcing source raises earned entrenchment; keep the
177            // highest authority a source has ever backed this value at.
178            state
179                .corroborating_sources
180                .entry(event.provenance.source.clone())
181                .and_modify(|level| *level = (*level).max(event.authority.level))
182                .or_insert(event.authority.level);
183        }
184        ClaimEventKind::Contradicted { by, .. } => {
185            // LFI "gentle explosion" tier: ordinary contradiction is tolerated and
186            // localized (-> Contested), but a contradiction against a canonical claim
187            // is a hard alarm, not a soft contest. A canonical fact that genuinely
188            // changed must be superseded by an equal-authority claim, not contradicted.
189            if state.authority.level == AuthorityLevel::Canonical {
190                return Err(TransitionError::CanonicalContradiction);
191            }
192            state.lifecycle = ClaimLifecycle::Contested;
193            if !state.contradicted_by.contains(by) {
194                state.contradicted_by.push(by.clone());
195            }
196        }
197        ClaimEventKind::Superseded { by, .. } => {
198            // Authority-as-entrenchment arbitration: a strictly lower-authority claim
199            // cannot supersede a higher-authority incumbent. This is the firewall's
200            // mitigation for MINJA-style memory injection by a low-privilege actor.
201            // Confidence is deliberately NOT consulted here (entrenchment != evidence).
202            if event.authority.level < state.authority.level {
203                return Err(TransitionError::InsufficientAuthority {
204                    incumbent: state.authority.level,
205                    challenger: event.authority.level,
206                });
207            }
208            state.lifecycle = ClaimLifecycle::Superseded;
209            state.superseded_by = Some(by.clone());
210        }
211        ClaimEventKind::Expired { .. } => {
212            // Explicit expiration is a terminal close, so it is authority-gated like
213            // retraction: a low-authority actor cannot make a trusted fact disappear by
214            // calling it "stale." TTL freshness remains a separate read-time predicate.
215            if event.authority.level < state.authority.level {
216                return Err(TransitionError::InsufficientAuthority {
217                    incumbent: state.authority.level,
218                    challenger: event.authority.level,
219                });
220            }
221            state.lifecycle = ClaimLifecycle::Expired;
222        }
223        ClaimEventKind::Retracted { .. } => {
224            // Retraction terminally *removes* a belief, so — unlike a `Contradicted`
225            // event, which is dissent and is deliberately not authority-gated — it is
226            // gated exactly like supersession: a strictly lower-authority actor cannot
227            // retract a higher-authority incumbent (ADR 0008). Retraction carries no
228            // backing claim, so there is no laundering indirection (the `arbitrate`
229            // anti-laundering check is supersession-only); this stated-authority gate is
230            // the complete check.
231            if event.authority.level < state.authority.level {
232                return Err(TransitionError::InsufficientAuthority {
233                    incumbent: state.authority.level,
234                    challenger: event.authority.level,
235                });
236            }
237            state.lifecycle = ClaimLifecycle::Retracted;
238        }
239        ClaimEventKind::Retrieved { .. } | ClaimEventKind::UsedInDecision { .. } => {}
240    }
241
242    state.updated_at = event.provenance.recorded_at;
243    state.last_event_id = event.event_id.clone();
244    Ok(state)
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub enum TransitionError {
249    InvalidEvent(crate::model::ValidationError),
250    MissingInitialAssertion,
251    DuplicateAssertion,
252    ClaimIdMismatch,
253    ClaimShapeMismatch,
254    ReinforcementValueMismatch,
255    TerminalStateMutation(ClaimLifecycle),
256    /// A belief-changing event was rejected because its authority strictly under-ranks
257    /// the incumbent's. Shared by **supersession** and **retraction** (ADR 0008): a
258    /// replacement or a retraction must out-rank or tie the incumbent. (Supersession is
259    /// additionally checked for laundering in `arbitrate`; retraction carries no backing
260    /// claim, so this authority gate is its complete check. Contradiction is *not* gated
261    /// — dissent is always admitted.)
262    InsufficientAuthority {
263        incumbent: AuthorityLevel,
264        challenger: AuthorityLevel,
265    },
266    /// A `claim.contradicted` event targeted a canonical claim. Canonical facts are
267    /// not softly contested; a genuine change must arrive as an equal-authority
268    /// supersession. This is the LFI hard-alarm tier.
269    CanonicalContradiction,
270}
271
272impl fmt::Display for TransitionError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            Self::InvalidEvent(error) => write!(f, "invalid event: {error}"),
276            Self::MissingInitialAssertion => {
277                f.write_str("claim stream must start with claim.asserted")
278            }
279            Self::DuplicateAssertion => {
280                f.write_str("claim stream cannot assert the same claim twice")
281            }
282            Self::ClaimIdMismatch => f.write_str("event claim_id does not match current state"),
283            Self::ClaimShapeMismatch => {
284                f.write_str("event subject or predicate does not match current state")
285            }
286            Self::ReinforcementValueMismatch => {
287                f.write_str("claim.reinforced cannot change the claim value")
288            }
289            Self::TerminalStateMutation(state) => {
290                write!(f, "cannot mutate terminal claim state {state:?}")
291            }
292            Self::InsufficientAuthority {
293                incumbent,
294                challenger,
295            } => write!(
296                f,
297                "insufficient authority: {challenger:?} may not override or remove an incumbent of {incumbent:?}"
298            ),
299            Self::CanonicalContradiction => {
300                f.write_str("a canonical claim cannot be contradicted; supersede it with equal authority instead")
301            }
302        }
303    }
304}
305
306impl std::error::Error for TransitionError {}
307
308#[cfg(test)]
309mod tests {
310    use crate::ids::{ActorId, ClaimEventId, ClaimId, EvidenceId, SourceId, TimestampMillis};
311    use crate::model::{
312        Authority, AuthorityLevel, ClaimEvent, ClaimEventKind, ClaimValue, Confidence,
313        ContradictionBasis, EntityRef, Evidence, EvidenceKind, ExpirationReason, Predicate,
314        Provenance, RetractionReason, SupersessionReason, Ttl,
315    };
316
317    use super::{ClaimLifecycle, TransitionError, apply_event};
318
319    const ALL_LEVELS: [AuthorityLevel; 5] = [
320        AuthorityLevel::Unknown,
321        AuthorityLevel::Low,
322        AuthorityLevel::Medium,
323        AuthorityLevel::High,
324        AuthorityLevel::Canonical,
325    ];
326
327    fn with_authority(mut event: ClaimEvent, level: AuthorityLevel) -> ClaimEvent {
328        event.authority = Authority {
329            level,
330            issuer: None,
331            scope: None,
332        };
333        event
334    }
335
336    fn asserted(level: AuthorityLevel) -> ClaimEvent {
337        with_authority(
338            base_event(
339                ClaimEventKind::Asserted,
340                "event:1",
341                Some(ClaimValue::Text("postgres".to_string())),
342            ),
343            level,
344        )
345    }
346
347    fn supersede(event_id: &str, level: AuthorityLevel) -> ClaimEvent {
348        with_authority(
349            base_event(
350                ClaimEventKind::Superseded {
351                    by: claim_id("claim:2"),
352                    reason: SupersessionReason::NewerObservation,
353                },
354                event_id,
355                None,
356            ),
357            level,
358        )
359    }
360
361    fn retract(event_id: &str, level: AuthorityLevel) -> ClaimEvent {
362        with_authority(
363            base_event(
364                ClaimEventKind::Retracted {
365                    reason: RetractionReason::UserDeleted,
366                },
367                event_id,
368                None,
369            ),
370            level,
371        )
372    }
373
374    fn expire(event_id: &str, level: AuthorityLevel) -> ClaimEvent {
375        with_authority(
376            base_event(
377                ClaimEventKind::Expired {
378                    reason: ExpirationReason::PolicyRetention,
379                },
380                event_id,
381                None,
382            ),
383            level,
384        )
385    }
386
387    fn claim_id(value: &str) -> ClaimId {
388        ClaimId::new(value).expect("valid claim id")
389    }
390
391    fn base_event(kind: ClaimEventKind, event_id: &str, value: Option<ClaimValue>) -> ClaimEvent {
392        ClaimEvent {
393            event_id: ClaimEventId::new(event_id).expect("valid event id"),
394            claim_id: claim_id("claim:1"),
395            kind,
396            subject: EntityRef::new("repo", "dent8").expect("valid entity"),
397            predicate: Predicate::new("uses_database").expect("valid predicate"),
398            value,
399            confidence: Confidence::from_millis(900).expect("valid confidence"),
400            authority: Authority::unknown(),
401            ttl: Ttl::Never,
402            provenance: Provenance {
403                source: SourceId::new("source:test").expect("valid source"),
404                actor: ActorId::new("actor:test").expect("valid actor"),
405                tool: Some("unit-test".to_string()),
406                run_id: None,
407                input_digest: None,
408                recorded_at: TimestampMillis::from_unix_millis(1),
409                attestation: None,
410            },
411            evidence: vec![Evidence {
412                id: EvidenceId::new("evidence:1").expect("valid evidence id"),
413                kind: EvidenceKind::UserStatement,
414                locator: "test".to_string(),
415                digest: None,
416                summary: Some("test evidence".to_string()),
417            }],
418            observed_at: None,
419            valid_from: None,
420        }
421    }
422
423    #[test]
424    fn assertion_creates_active_state() {
425        let event = base_event(
426            ClaimEventKind::Asserted,
427            "event:1",
428            Some(ClaimValue::Text("postgres".to_string())),
429        );
430
431        let state = apply_event(None, &event).expect("assertion applies");
432
433        assert_eq!(state.lifecycle, ClaimLifecycle::Active);
434        assert_eq!(state.evidence_count, 1);
435    }
436
437    #[test]
438    fn duplicate_assertion_is_rejected() {
439        let event = base_event(
440            ClaimEventKind::Asserted,
441            "event:1",
442            Some(ClaimValue::Text("postgres".to_string())),
443        );
444        let state = apply_event(None, &event).expect("assertion applies");
445
446        let error = apply_event(Some(state), &event).expect_err("duplicate rejected");
447
448        assert_eq!(error, TransitionError::DuplicateAssertion);
449    }
450
451    #[test]
452    fn contradiction_marks_claim_contested() {
453        let asserted = base_event(
454            ClaimEventKind::Asserted,
455            "event:1",
456            Some(ClaimValue::Text("postgres".to_string())),
457        );
458        let state = apply_event(None, &asserted).expect("assertion applies");
459        let contradicted = base_event(
460            ClaimEventKind::Contradicted {
461                by: claim_id("claim:2"),
462                basis: ContradictionBasis::SamePredicateDifferentValue,
463            },
464            "event:2",
465            None,
466        );
467
468        let state = apply_event(Some(state), &contradicted).expect("contradiction applies");
469
470        assert_eq!(state.lifecycle, ClaimLifecycle::Contested);
471        assert_eq!(state.contradicted_by, vec![claim_id("claim:2")]);
472    }
473
474    #[test]
475    fn terminal_state_rejects_lifecycle_mutation() {
476        let asserted = base_event(
477            ClaimEventKind::Asserted,
478            "event:1",
479            Some(ClaimValue::Text("postgres".to_string())),
480        );
481        let state = apply_event(None, &asserted).expect("assertion applies");
482        let superseded = base_event(
483            ClaimEventKind::Superseded {
484                by: claim_id("claim:2"),
485                reason: SupersessionReason::NewerObservation,
486            },
487            "event:2",
488            None,
489        );
490        let state = apply_event(Some(state), &superseded).expect("supersession applies");
491        let reinforced = base_event(
492            ClaimEventKind::Reinforced {
493                by: claim_id("claim:3"),
494            },
495            "event:3",
496            Some(ClaimValue::Text("postgres".to_string())),
497        );
498
499        let error = apply_event(Some(state), &reinforced).expect_err("terminal mutation rejected");
500
501        assert_eq!(
502            error,
503            TransitionError::TerminalStateMutation(ClaimLifecycle::Superseded)
504        );
505    }
506
507    #[test]
508    fn retrieval_does_not_change_lifecycle() {
509        let asserted = base_event(
510            ClaimEventKind::Asserted,
511            "event:1",
512            Some(ClaimValue::Text("postgres".to_string())),
513        );
514        let state = apply_event(None, &asserted).expect("assertion applies");
515        let retrieved = base_event(
516            ClaimEventKind::Retrieved {
517                purpose: "context".to_string(),
518            },
519            "event:2",
520            None,
521        );
522
523        let state = apply_event(Some(state), &retrieved).expect("retrieval applies");
524
525        assert_eq!(state.lifecycle, ClaimLifecycle::Active);
526    }
527
528    #[test]
529    fn lower_authority_supersession_is_rejected() {
530        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
531
532        let error = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Low))
533            .expect_err("low-authority supersession rejected");
534
535        assert_eq!(
536            error,
537            TransitionError::InsufficientAuthority {
538                incumbent: AuthorityLevel::High,
539                challenger: AuthorityLevel::Low,
540            }
541        );
542    }
543
544    #[test]
545    fn equal_authority_supersession_succeeds() {
546        let state =
547            apply_event(None, &asserted(AuthorityLevel::Medium)).expect("assertion applies");
548
549        let state = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Medium))
550            .expect("equal-authority supersession applies");
551
552        assert_eq!(state.lifecycle, ClaimLifecycle::Superseded);
553    }
554
555    #[test]
556    fn higher_authority_supersession_succeeds() {
557        let state = apply_event(None, &asserted(AuthorityLevel::Low)).expect("assertion applies");
558
559        let state = apply_event(
560            Some(state),
561            &supersede("event:2", AuthorityLevel::Canonical),
562        )
563        .expect("higher-authority supersession applies");
564
565        assert_eq!(state.lifecycle, ClaimLifecycle::Superseded);
566    }
567
568    #[test]
569    fn contradicting_a_canonical_claim_hard_alarms() {
570        let state =
571            apply_event(None, &asserted(AuthorityLevel::Canonical)).expect("assertion applies");
572        let contradicted = base_event(
573            ClaimEventKind::Contradicted {
574                by: claim_id("claim:2"),
575                basis: ContradictionBasis::SamePredicateDifferentValue,
576            },
577            "event:2",
578            None,
579        );
580
581        let error = apply_event(Some(state), &contradicted)
582            .expect_err("canonical contradiction hard-alarms");
583
584        assert_eq!(error, TransitionError::CanonicalContradiction);
585    }
586
587    #[test]
588    fn contradicting_a_non_canonical_claim_still_contests() {
589        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
590        let contradicted = base_event(
591            ClaimEventKind::Contradicted {
592                by: claim_id("claim:2"),
593                basis: ContradictionBasis::SamePredicateDifferentValue,
594            },
595            "event:2",
596            None,
597        );
598
599        let state =
600            apply_event(Some(state), &contradicted).expect("non-canonical contradiction applies");
601
602        assert_eq!(state.lifecycle, ClaimLifecycle::Contested);
603    }
604
605    /// Exhaustive bounded proof over the finite `AuthorityLevel` lattice: for every
606    /// (incumbent, challenger) pair, a supersession is accepted iff the challenger does
607    /// not under-rank the incumbent; and once accepted, the claim is terminal and
608    /// cannot be resurrected by any later event — even a canonical one. This is the
609    /// runnable form of the rank-1 "verified non-resurrection" invariant; the
610    /// `#[cfg(kani)]` harness in `proofs` checks the same property symbolically.
611    #[test]
612    fn authority_monotone_supersession_and_non_resurrection() {
613        for incumbent in ALL_LEVELS {
614            for challenger in ALL_LEVELS {
615                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
616                let result = apply_event(Some(state), &supersede("event:2", challenger));
617
618                if challenger < incumbent {
619                    assert_eq!(
620                        result,
621                        Err(TransitionError::InsufficientAuthority {
622                            incumbent,
623                            challenger,
624                        }),
625                        "challenger {challenger:?} should not supersede incumbent {incumbent:?}",
626                    );
627                    continue;
628                }
629
630                let superseded = result.expect("non-under-ranking supersession applies");
631                assert!(
632                    superseded.lifecycle.is_terminal(),
633                    "supersession should make the claim terminal",
634                );
635
636                // Non-resurrection: no later event, even a canonical supersession,
637                // returns a terminal claim to an active/believed state.
638                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
639                let error = apply_event(Some(superseded), &resurrect)
640                    .expect_err("terminal claim cannot be resurrected");
641                assert_eq!(
642                    error,
643                    TransitionError::TerminalStateMutation(ClaimLifecycle::Superseded)
644                );
645            }
646        }
647    }
648
649    /// The retraction counterpart of the supersession lattice (ADR 0008): a `Retracted`
650    /// event is accepted iff the retractor does not under-rank the incumbent, and once
651    /// accepted the claim is terminally `Retracted` and cannot be resurrected. Retraction
652    /// removes a belief, so it is authority-gated like supersession — unlike a
653    /// `Contradicted` event, which is dissent and is admitted at any authority.
654    #[test]
655    fn authority_monotone_retraction_and_non_resurrection() {
656        for incumbent in ALL_LEVELS {
657            for challenger in ALL_LEVELS {
658                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
659                let result = apply_event(Some(state), &retract("event:2", challenger));
660
661                if challenger < incumbent {
662                    assert_eq!(
663                        result,
664                        Err(TransitionError::InsufficientAuthority {
665                            incumbent,
666                            challenger,
667                        }),
668                        "retractor {challenger:?} should not retract incumbent {incumbent:?}",
669                    );
670                    continue;
671                }
672
673                let retracted = result.expect("non-under-ranking retraction applies");
674                assert_eq!(retracted.lifecycle, ClaimLifecycle::Retracted);
675                assert!(retracted.lifecycle.is_terminal());
676
677                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
678                let error = apply_event(Some(retracted), &resurrect)
679                    .expect_err("terminal claim cannot be resurrected");
680                assert_eq!(
681                    error,
682                    TransitionError::TerminalStateMutation(ClaimLifecycle::Retracted)
683                );
684            }
685        }
686    }
687
688    /// Explicit expiration is also a terminal close. TTL staleness is read-time and needs no
689    /// actor authority, but a `claim.expired` event changes the durable lifecycle, so it must
690    /// not under-rank the incumbent.
691    #[test]
692    fn authority_monotone_expiration_and_non_resurrection() {
693        for incumbent in ALL_LEVELS {
694            for challenger in ALL_LEVELS {
695                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
696                let result = apply_event(Some(state), &expire("event:2", challenger));
697
698                if challenger < incumbent {
699                    assert_eq!(
700                        result,
701                        Err(TransitionError::InsufficientAuthority {
702                            incumbent,
703                            challenger,
704                        }),
705                        "expirer {challenger:?} should not expire incumbent {incumbent:?}",
706                    );
707                    continue;
708                }
709
710                let expired = result.expect("non-under-ranking expiration applies");
711                assert_eq!(expired.lifecycle, ClaimLifecycle::Expired);
712                assert!(expired.lifecycle.is_terminal());
713
714                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
715                let error = apply_event(Some(expired), &resurrect)
716                    .expect_err("terminal claim cannot be resurrected");
717                assert_eq!(
718                    error,
719                    TransitionError::TerminalStateMutation(ClaimLifecycle::Expired)
720                );
721            }
722        }
723    }
724}
725
726/// Rank-1 "verified non-resurrection" harness. Bounded model check of the
727/// authority-weighted supersession gate with Kani (`cargo kani`). Excluded from
728/// normal builds via `#[cfg(kani)]`; see `docs/formal-verification.md` and
729/// `docs/research/novelty.md`. Authority levels are symbolic (`kani::any`),
730/// everything else is fixed, keeping the proof tractable.
731#[cfg(kani)]
732mod proofs {
733    use crate::ids::{ActorId, ClaimEventId, ClaimId, EvidenceId, SourceId, TimestampMillis};
734    use crate::model::{
735        Authority, AuthorityLevel, ClaimEvent, ClaimEventKind, ClaimValue, Confidence, EntityRef,
736        Evidence, EvidenceKind, ExpirationReason, Predicate, Provenance, SupersessionReason, Ttl,
737    };
738
739    use super::apply_event;
740
741    fn level_from(n: u8) -> AuthorityLevel {
742        match n {
743            0 => AuthorityLevel::Unknown,
744            1 => AuthorityLevel::Low,
745            2 => AuthorityLevel::Medium,
746            3 => AuthorityLevel::High,
747            _ => AuthorityLevel::Canonical,
748        }
749    }
750
751    fn event(
752        kind: ClaimEventKind,
753        event_id: &str,
754        value: Option<ClaimValue>,
755        level: AuthorityLevel,
756    ) -> ClaimEvent {
757        ClaimEvent {
758            event_id: ClaimEventId::new(event_id).unwrap(),
759            claim_id: ClaimId::new("claim:1").unwrap(),
760            kind,
761            subject: EntityRef::new("repo", "dent8").unwrap(),
762            predicate: Predicate::new("uses_database").unwrap(),
763            value,
764            confidence: Confidence::from_millis(900).unwrap(),
765            authority: Authority {
766                level,
767                issuer: None,
768                scope: None,
769            },
770            ttl: Ttl::Never,
771            provenance: Provenance {
772                source: SourceId::new("source:test").unwrap(),
773                actor: ActorId::new("actor:test").unwrap(),
774                tool: None,
775                run_id: None,
776                input_digest: None,
777                recorded_at: TimestampMillis::from_unix_millis(1),
778                attestation: None,
779            },
780            evidence: vec![Evidence {
781                id: EvidenceId::new("evidence:1").unwrap(),
782                kind: EvidenceKind::UserStatement,
783                locator: "x".to_string(),
784                digest: None,
785                summary: None,
786            }],
787            observed_at: None,
788            valid_from: None,
789        }
790    }
791
792    #[kani::proof]
793    fn supersession_is_authority_monotone_and_non_resurrecting() {
794        let a: u8 = kani::any();
795        let b: u8 = kani::any();
796        kani::assume(a < 5);
797        kani::assume(b < 5);
798        let incumbent = level_from(a);
799        let challenger = level_from(b);
800
801        let asserted = event(
802            ClaimEventKind::Asserted,
803            "event:1",
804            Some(ClaimValue::Text("postgres".to_string())),
805            incumbent,
806        );
807        let state = apply_event(None, &asserted).unwrap();
808
809        let superseded = event(
810            ClaimEventKind::Superseded {
811                by: ClaimId::new("claim:2").unwrap(),
812                reason: SupersessionReason::NewerObservation,
813            },
814            "event:2",
815            None,
816            challenger,
817        );
818
819        match apply_event(Some(state), &superseded) {
820            Ok(next) => {
821                // Accepted only when the challenger does not under-rank the incumbent,
822                // and the result is terminal.
823                assert!(challenger >= incumbent);
824                assert!(next.lifecycle.is_terminal());
825
826                // Non-resurrection: even a canonical event cannot re-activate it.
827                let resurrect = event(
828                    ClaimEventKind::Superseded {
829                        by: ClaimId::new("claim:3").unwrap(),
830                        reason: SupersessionReason::NewerObservation,
831                    },
832                    "event:3",
833                    None,
834                    AuthorityLevel::Canonical,
835                );
836                assert!(apply_event(Some(next), &resurrect).is_err());
837            }
838            Err(_) => {
839                // Rejected only when the challenger strictly under-ranks the incumbent.
840                assert!(challenger < incumbent);
841            }
842        }
843    }
844
845    #[kani::proof]
846    fn retraction_is_authority_monotone_and_non_resurrecting() {
847        use crate::model::RetractionReason;
848
849        let a: u8 = kani::any();
850        let b: u8 = kani::any();
851        kani::assume(a < 5);
852        kani::assume(b < 5);
853        let incumbent = level_from(a);
854        let challenger = level_from(b);
855
856        let asserted = event(
857            ClaimEventKind::Asserted,
858            "event:1",
859            Some(ClaimValue::Text("postgres".to_string())),
860            incumbent,
861        );
862        let state = apply_event(None, &asserted).unwrap();
863
864        let retracted = event(
865            ClaimEventKind::Retracted {
866                reason: RetractionReason::UserDeleted,
867            },
868            "event:2",
869            None,
870            challenger,
871        );
872
873        match apply_event(Some(state), &retracted) {
874            Ok(next) => {
875                assert!(challenger >= incumbent);
876                assert!(next.lifecycle.is_terminal());
877
878                let resurrect = event(
879                    ClaimEventKind::Superseded {
880                        by: ClaimId::new("claim:3").unwrap(),
881                        reason: SupersessionReason::NewerObservation,
882                    },
883                    "event:3",
884                    None,
885                    AuthorityLevel::Canonical,
886                );
887                assert!(apply_event(Some(next), &resurrect).is_err());
888            }
889            Err(_) => {
890                assert!(challenger < incumbent);
891            }
892        }
893    }
894
895    #[kani::proof]
896    fn expiration_is_authority_monotone_and_non_resurrecting() {
897        let a: u8 = kani::any();
898        let b: u8 = kani::any();
899        kani::assume(a < 5);
900        kani::assume(b < 5);
901        let incumbent = level_from(a);
902        let challenger = level_from(b);
903
904        let asserted = event(
905            ClaimEventKind::Asserted,
906            "event:1",
907            Some(ClaimValue::Text("postgres".to_string())),
908            incumbent,
909        );
910        let state = apply_event(None, &asserted).unwrap();
911
912        let expired = event(
913            ClaimEventKind::Expired {
914                reason: ExpirationReason::PolicyRetention,
915            },
916            "event:2",
917            None,
918            challenger,
919        );
920
921        match apply_event(Some(state), &expired) {
922            Ok(next) => {
923                assert!(challenger >= incumbent);
924                assert!(next.lifecycle.is_terminal());
925
926                let resurrect = event(
927                    ClaimEventKind::Superseded {
928                        by: ClaimId::new("claim:3").unwrap(),
929                        reason: SupersessionReason::NewerObservation,
930                    },
931                    "event:3",
932                    None,
933                    AuthorityLevel::Canonical,
934                );
935                assert!(apply_event(Some(next), &resurrect).is_err());
936            }
937            Err(_) => {
938                assert!(challenger < incumbent);
939            }
940        }
941    }
942}