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    /// Valid-time upper bound captured at assertion (ADR 0016): the instant the fact is
46    /// asserted to stop holding. Read-time freshness treats an elapsed `valid_to` exactly
47    /// like an elapsed TTL; lifecycle is untouched. `#[serde(default)]` so projections
48    /// materialized before the field existed still deserialize.
49    #[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    /// The distinct provenance sources that have *asserted or reinforced this exact
59    /// value*, each mapped to the highest authority it backed at. This measures
60    /// same-value corroboration only — surviving a challenge is the *separate*
61    /// entrenchment signal tracked in [`ClaimState::survived_challenges`] (ADR 0015).
62    /// The asserter is the first entry.
63    pub corroborating_sources: BTreeMap<SourceId, AuthorityLevel>,
64    /// The distinct sources whose challenge against this claim the firewall rejected,
65    /// each mapped to the highest *effective* authority it challenged at (ADR 0015) —
66    /// the "attacked and stood" half of earned entrenchment. `#[serde(default)]` so
67    /// projections materialized before the field existed still deserialize.
68    #[serde(default)]
69    pub survived_challenges: BTreeMap<SourceId, AuthorityLevel>,
70}
71
72impl ClaimState {
73    /// Raw earned-entrenchment degree: the number of distinct sources backing this
74    /// value. **Sybil-inflatable** on its own (an attacker minting many sources raises
75    /// it), so security decisions should use [`ClaimState::corroboration_at_or_above`]
76    /// to count only sufficiently-authoritative backers.
77    #[must_use]
78    pub fn corroboration(&self) -> usize {
79        self.corroborating_sources.len()
80    }
81
82    /// Authority-weighted corroboration: the number of distinct backing sources whose
83    /// authority is at least `min`. This is the Sybil-resistant entrenchment signal —
84    /// minting low-authority sources cannot raise the count measured at a higher floor.
85    #[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    /// Raw survived-challenge degree: distinct sources whose challenge this claim
94    /// survived. **Sybil-inflatable** like [`ClaimState::corroboration`]; security
95    /// decisions should use [`ClaimState::survived_challenges_at_or_above`].
96    #[must_use]
97    pub fn survived_challenge_count(&self) -> usize {
98        self.survived_challenges.len()
99    }
100
101    /// Authority-weighted survived challenges: distinct challengers whose *effective*
102    /// authority was at least `min` when they challenged and lost. Minting low-authority
103    /// challengers cannot simulate surviving strong attacks.
104    #[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    /// When this claim stops being fresh, if ever: the **earliest** of its TTL bound
113    /// (relative to the freshness anchor) and its asserted `valid_to` (ADR 0016).
114    #[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    /// Whether the claim's freshness has elapsed at `now` — its TTL ran out **or** its
123    /// asserted validity ended. A claim with `Ttl::Never` and no `valid_to` is never
124    /// expired. This is the read-time freshness predicate; it does not consult lifecycle
125    /// (a `superseded` claim can still be "unexpired" by TTL).
126    #[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            // A distinct reinforcing source raises earned entrenchment; keep the
215            // highest authority a source has ever backed this value at.
216            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            // LFI "gentle explosion" tier: ordinary contradiction is tolerated and
224            // localized (-> Contested), but a contradiction against a canonical claim
225            // is a hard alarm, not a soft contest. A canonical fact that genuinely
226            // changed must be superseded by an equal-authority claim, not contradicted.
227            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            // Authority-as-entrenchment arbitration: a strictly lower-authority claim
237            // cannot supersede a higher-authority incumbent. This is the firewall's
238            // mitigation for MINJA-style memory injection by a low-privilege actor.
239            // Confidence is deliberately NOT consulted here (entrenchment != evidence).
240            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            // Explicit expiration is a terminal close, so it is authority-gated like
251            // retraction: a low-authority actor cannot make a trusted fact disappear by
252            // calling it "stale." TTL freshness remains a separate read-time predicate.
253            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            // Retraction terminally *removes* a belief, so — unlike a `Contradicted`
263            // event, which is dissent and is deliberately not authority-gated — it is
264            // gated exactly like supersession: a strictly lower-authority actor cannot
265            // retract a higher-authority incumbent (ADR 0008). Retraction carries no
266            // backing claim, so there is no laundering indirection (the `arbitrate`
267            // anti-laundering check is supersession-only); this stated-authority gate is
268            // the complete check.
269            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            // Surviving a challenge is earned entrenchment (ADR 0015): record the
280            // challenger at the highest effective authority it ever challenged and lost
281            // at. Bookkeeping only — lifecycle, value, and authority are untouched, and
282            // there is deliberately no authority gate here (the record carries the
283            // *challenger's* authority, which by construction lost to the incumbent's).
284            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    /// A belief-changing event was rejected because its authority strictly under-ranks
307    /// the incumbent's. Shared by **supersession** and **retraction** (ADR 0008): a
308    /// replacement or a retraction must out-rank or tie the incumbent. (Supersession is
309    /// additionally checked for laundering in `arbitrate`; retraction carries no backing
310    /// claim, so this authority gate is its complete check. Contradiction is *not* gated
311    /// — dissent is always admitted.)
312    InsufficientAuthority {
313        incumbent: AuthorityLevel,
314        challenger: AuthorityLevel,
315    },
316    /// A `claim.contradicted` event targeted a canonical claim. Canonical facts are
317    /// not softly contested; a genuine change must arrive as an equal-authority
318    /// supersession. This is the LFI hard-alarm tier.
319    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        // Two challenges from the same source at rising strength, one from another.
495        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        // Bookkeeping only: the incumbent's belief state is untouched.
512        assert_eq!(state.lifecycle, ClaimLifecycle::Active);
513        assert_eq!(state.authority.level, AuthorityLevel::High);
514        // Distinct challengers, each at the strongest level they lost at.
515        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        // Cannot open a stream.
530        assert!(matches!(
531            apply_event(
532                None,
533                &challenge_rejected("event:1", "source:attacker", AuthorityLevel::Low)
534            ),
535            Err(TransitionError::MissingInitialAssertion)
536        ));
537        // Cannot land on a fallen incumbent: a challenge against a terminal claim was not
538        // "survived" — the claim already fell.
539        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        // Projections materialized before ADR 0015 lack the field; serde(default) admits them.
556        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        // No TTL: expiry is the validity bound alone, inclusive at the boundary.
574        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        // With a TTL, the earliest bound wins in both directions.
582        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    /// Exhaustive bounded proof over the finite `AuthorityLevel` lattice: for every
800    /// (incumbent, challenger) pair, a supersession is accepted iff the challenger does
801    /// not under-rank the incumbent; and once accepted, the claim is terminal and
802    /// cannot be resurrected by any later event — even a canonical one. This is the
803    /// runnable form of the rank-1 "verified non-resurrection" invariant; the
804    /// `#[cfg(kani)]` harness in `proofs` checks the same property symbolically.
805    #[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                // Non-resurrection: no later event, even a canonical supersession,
831                // returns a terminal claim to an active/believed state.
832                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    /// The retraction counterpart of the supersession lattice (ADR 0008): a `Retracted`
844    /// event is accepted iff the retractor does not under-rank the incumbent, and once
845    /// accepted the claim is terminally `Retracted` and cannot be resurrected. Retraction
846    /// removes a belief, so it is authority-gated like supersession — unlike a
847    /// `Contradicted` event, which is dissent and is admitted at any authority.
848    #[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    /// Explicit expiration is also a terminal close. TTL staleness is read-time and needs no
883    /// actor authority, but a `claim.expired` event changes the durable lifecycle, so it must
884    /// not under-rank the incumbent.
885    #[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/// Rank-1 "verified non-resurrection" harness. Bounded model check of the
921/// authority-weighted supersession gate with Kani (`cargo kani`). Excluded from
922/// normal builds via `#[cfg(kani)]`; see `docs/formal-verification.md` and
923/// `docs/research/novelty.md`. Authority levels are symbolic (`kani::any`),
924/// everything else is fixed, keeping the proof tractable.
925#[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                // Accepted only when the challenger does not under-rank the incumbent,
1017                // and the result is terminal.
1018                assert!(challenger >= incumbent);
1019                assert!(next.lifecycle.is_terminal());
1020
1021                // Non-resurrection: even a canonical event cannot re-activate it.
1022                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                // Rejected only when the challenger strictly under-ranks the incumbent.
1035                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}