Skip to main content

dent8_core/
state.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::ids::{FactEventId, FactId, SourceId, TimestampMillis};
7use crate::model::{
8    Authority, AuthorityLevel, FactEvent, FactEventKind, FactValue, Predicate, Subject, Ttl,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12pub enum FactLifecycle {
13    Active,
14    Contested,
15    Superseded,
16    Expired,
17    Retracted,
18}
19
20impl FactLifecycle {
21    #[must_use]
22    pub const fn is_terminal(self) -> bool {
23        matches!(self, Self::Superseded | Self::Expired | Self::Retracted)
24    }
25}
26
27/// The current projected state of a fact — 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 FactState {
32    pub fact_id: FactId,
33    pub subject: Subject,
34    pub predicate: Predicate,
35    pub value: FactValue,
36    /// Entrenchment of the incumbent fact, 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 [`FactState::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    /// The asserted valid-time **lower** bound (ADR 0016), kept distinct from
46    /// `freshness_anchor` (which falls back to `observed_at`/`recorded_at`): a fact whose
47    /// `valid_from` is in the future is **not yet valid**, so read-time freshness treats it
48    /// as not-fresh until then. `#[serde(default)]` for projections materialized before it.
49    #[serde(default)]
50    pub valid_from: Option<TimestampMillis>,
51    /// Valid-time upper bound captured at assertion (ADR 0016): the instant the fact is
52    /// asserted to stop holding. Read-time freshness treats an elapsed `valid_to` exactly
53    /// like an elapsed TTL; lifecycle is untouched. `#[serde(default)]` so projections
54    /// materialized before the field existed still deserialize.
55    #[serde(default)]
56    pub valid_to: Option<TimestampMillis>,
57    pub lifecycle: FactLifecycle,
58    pub created_at: TimestampMillis,
59    pub updated_at: TimestampMillis,
60    pub last_event_id: FactEventId,
61    pub superseded_by: Option<FactId>,
62    pub contradicted_by: Vec<FactId>,
63    pub evidence_count: usize,
64    /// The distinct provenance sources that have *asserted or reinforced this exact
65    /// value*, each mapped to the highest authority it backed at. This measures
66    /// same-value corroboration only — surviving a challenge is the *separate*
67    /// entrenchment signal tracked in [`FactState::survived_challenges`] (ADR 0015).
68    /// The asserter is the first entry.
69    pub corroborating_sources: BTreeMap<SourceId, AuthorityLevel>,
70    /// The distinct sources whose challenge against this fact the firewall rejected,
71    /// each mapped to the highest *effective* authority it challenged at (ADR 0015) —
72    /// the "attacked and stood" half of earned entrenchment. `#[serde(default)]` so
73    /// projections materialized before the field existed still deserialize.
74    #[serde(default)]
75    pub survived_challenges: BTreeMap<SourceId, AuthorityLevel>,
76}
77
78impl FactState {
79    /// Raw earned-entrenchment degree: the number of distinct sources backing this
80    /// value. **Sybil-inflatable** on its own (an attacker minting many sources raises
81    /// it), so security decisions should use [`FactState::corroboration_at_or_above`]
82    /// to count only sufficiently-authoritative backers.
83    #[must_use]
84    pub fn corroboration(&self) -> usize {
85        self.corroborating_sources.len()
86    }
87
88    /// Authority-weighted corroboration: the number of distinct backing sources whose
89    /// authority is at least `min`. This is the Sybil-resistant entrenchment signal —
90    /// minting low-authority sources cannot raise the count measured at a higher floor.
91    #[must_use]
92    pub fn corroboration_at_or_above(&self, min: AuthorityLevel) -> usize {
93        self.corroborating_sources
94            .values()
95            .filter(|&&level| level >= min)
96            .count()
97    }
98
99    /// Raw survived-challenge degree: distinct sources whose challenge this fact
100    /// survived. **Sybil-inflatable** like [`FactState::corroboration`]; security
101    /// decisions should use [`FactState::survived_challenges_at_or_above`].
102    #[must_use]
103    pub fn survived_challenge_count(&self) -> usize {
104        self.survived_challenges.len()
105    }
106
107    /// Authority-weighted survived challenges: distinct challengers whose *effective*
108    /// authority was at least `min` when they challenged and lost. Minting low-authority
109    /// challengers cannot simulate surviving strong attacks.
110    #[must_use]
111    pub fn survived_challenges_at_or_above(&self, min: AuthorityLevel) -> usize {
112        self.survived_challenges
113            .values()
114            .filter(|&&level| level >= min)
115            .count()
116    }
117
118    /// **Earned entrenchment** at `min` (ADR 0017): the sum of both Sybil-resistant halves —
119    /// independent backing ([`Self::corroboration_at_or_above`]) plus
120    /// having-been-attacked-and-held ([`Self::survived_challenges_at_or_above`]), each counted
121    /// only at authority ≥ `min`. This is the measure the opt-in supersession gate and the
122    /// subject-level unearned-supersession audit compare, so a fact that survived challenges
123    /// resists an equal-authority replacement as much as one with extra backing does.
124    #[must_use]
125    pub fn earned_entrenchment_at_or_above(&self, min: AuthorityLevel) -> usize {
126        self.corroboration_at_or_above(min) + self.survived_challenges_at_or_above(min)
127    }
128
129    /// When this fact stops being fresh, if ever: the **earliest** of its TTL bound
130    /// (relative to the freshness anchor) and its asserted `valid_to` (ADR 0016).
131    #[must_use]
132    pub fn expires_at(&self) -> Option<TimestampMillis> {
133        match (self.ttl.expires_at(self.freshness_anchor), self.valid_to) {
134            (Some(ttl), Some(valid_to)) => Some(ttl.min(valid_to)),
135            (ttl, valid_to) => ttl.or(valid_to),
136        }
137    }
138
139    /// Whether the fact is **not yet valid** at `now` (ADR 0016): its asserted `valid_from`
140    /// is in the future. Distinct from expiry — a not-yet-valid fact reads as not-fresh
141    /// because it has not started holding, not because it has stopped.
142    #[must_use]
143    pub fn is_not_yet_valid_at(&self, now: TimestampMillis) -> bool {
144        self.valid_from.is_some_and(|from| now < from)
145    }
146
147    /// Whether the fact is **fresh** at `now`: within its validity window — at or after
148    /// `valid_from` (if set) and not past its TTL / `valid_to` upper bound. This is the
149    /// read-time freshness predicate the receipt's `fresh` flag reports; it does not consult
150    /// lifecycle (a `superseded` fact can still be within its window).
151    #[must_use]
152    pub fn is_fresh_at(&self, now: TimestampMillis) -> bool {
153        !self.is_not_yet_valid_at(now) && !self.is_expired_at(now)
154    }
155
156    /// Whether the fact's freshness has elapsed at `now` — its TTL ran out **or** its
157    /// asserted validity ended. A fact with `Ttl::Never` and no `valid_to` is never
158    /// expired. Purely the **upper** bound; the lower bound is [`Self::is_not_yet_valid_at`].
159    /// It does not consult lifecycle (a `superseded` fact can still be "unexpired" by TTL).
160    #[must_use]
161    pub fn is_expired_at(&self, now: TimestampMillis) -> bool {
162        self.expires_at()
163            .is_some_and(|expires_at| expires_at <= now)
164    }
165}
166
167pub fn apply_event(
168    current: Option<FactState>,
169    event: &FactEvent,
170) -> Result<FactState, TransitionError> {
171    event.validate().map_err(TransitionError::InvalidEvent)?;
172
173    match current {
174        None => apply_initial_event(event),
175        Some(state) => apply_next_event(state, event),
176    }
177}
178
179fn apply_initial_event(event: &FactEvent) -> Result<FactState, TransitionError> {
180    if !matches!(event.kind, FactEventKind::Asserted) {
181        return Err(TransitionError::MissingInitialAssertion);
182    }
183
184    let value = event
185        .value
186        .clone()
187        .ok_or(TransitionError::MissingInitialAssertion)?;
188
189    let freshness_anchor = event
190        .valid_from
191        .or(event.observed_at)
192        .unwrap_or(event.provenance.recorded_at);
193
194    Ok(FactState {
195        fact_id: event.fact_id.clone(),
196        subject: event.subject.clone(),
197        predicate: event.predicate.clone(),
198        value,
199        authority: event.authority.clone(),
200        ttl: event.ttl.clone(),
201        freshness_anchor,
202        valid_from: event.valid_from,
203        valid_to: event.valid_to,
204        lifecycle: FactLifecycle::Active,
205        created_at: event.provenance.recorded_at,
206        updated_at: event.provenance.recorded_at,
207        last_event_id: event.event_id.clone(),
208        superseded_by: None,
209        contradicted_by: Vec::new(),
210        evidence_count: event.evidence.len(),
211        corroborating_sources: BTreeMap::from([(
212            event.provenance.source.clone(),
213            event.authority.level,
214        )]),
215        survived_challenges: BTreeMap::new(),
216    })
217}
218
219fn apply_next_event(mut state: FactState, event: &FactEvent) -> Result<FactState, TransitionError> {
220    if state.fact_id != event.fact_id {
221        return Err(TransitionError::FactIdMismatch);
222    }
223
224    if state.subject != event.subject || state.predicate != event.predicate {
225        return Err(TransitionError::FactShapeMismatch);
226    }
227
228    if state.lifecycle.is_terminal()
229        && !matches!(
230            event.kind,
231            FactEventKind::Retrieved { .. } | FactEventKind::UsedInDecision { .. }
232        )
233    {
234        return Err(TransitionError::TerminalStateMutation(state.lifecycle));
235    }
236
237    match &event.kind {
238        FactEventKind::Asserted => return Err(TransitionError::DuplicateAssertion),
239        FactEventKind::Reinforced { .. } => {
240            if let Some(value) = &event.value
241                && value != &state.value
242            {
243                return Err(TransitionError::ReinforcementValueMismatch);
244            }
245            state.evidence_count += event.evidence.len();
246            // A distinct reinforcing source raises earned entrenchment; keep the
247            // highest authority a source has ever backed this value at.
248            state
249                .corroborating_sources
250                .entry(event.provenance.source.clone())
251                .and_modify(|level| *level = (*level).max(event.authority.level))
252                .or_insert(event.authority.level);
253        }
254        FactEventKind::Contradicted { by, .. } => {
255            // LFI "gentle explosion" tier: ordinary contradiction is tolerated and
256            // localized (-> Contested), but a contradiction against a canonical fact
257            // is a hard alarm, not a soft contest. A canonical fact that genuinely
258            // changed must be superseded by an equal-authority fact, not contradicted.
259            if state.authority.level == AuthorityLevel::Canonical {
260                return Err(TransitionError::CanonicalContradiction);
261            }
262            state.lifecycle = FactLifecycle::Contested;
263            if !state.contradicted_by.contains(by) {
264                state.contradicted_by.push(by.clone());
265            }
266        }
267        FactEventKind::Superseded { by, .. } => {
268            // Authority-as-entrenchment arbitration: a strictly lower-authority fact
269            // cannot supersede a higher-authority incumbent. This is the firewall's
270            // mitigation for MINJA-style memory injection by a low-privilege actor.
271            // Confidence is deliberately NOT consulted here (entrenchment != evidence).
272            if event.authority.level < state.authority.level {
273                return Err(TransitionError::InsufficientAuthority {
274                    incumbent: state.authority.level,
275                    challenger: event.authority.level,
276                });
277            }
278            state.lifecycle = FactLifecycle::Superseded;
279            state.superseded_by = Some(by.clone());
280        }
281        FactEventKind::Expired { .. } => {
282            // Explicit expiration is a terminal close, so it is authority-gated like
283            // retraction: a low-authority actor cannot make a trusted fact disappear by
284            // calling it "stale." TTL freshness remains a separate read-time predicate.
285            if event.authority.level < state.authority.level {
286                return Err(TransitionError::InsufficientAuthority {
287                    incumbent: state.authority.level,
288                    challenger: event.authority.level,
289                });
290            }
291            state.lifecycle = FactLifecycle::Expired;
292        }
293        FactEventKind::Retracted { .. } => {
294            // Retraction terminally *removes* a belief, so — unlike a `Contradicted`
295            // event, which is dissent and is deliberately not authority-gated — it is
296            // gated exactly like supersession: a strictly lower-authority actor cannot
297            // retract a higher-authority incumbent (ADR 0008). Retraction carries no
298            // backing fact, so there is no laundering indirection (the `arbitrate`
299            // anti-laundering check is supersession-only); this stated-authority gate is
300            // the complete check.
301            if event.authority.level < state.authority.level {
302                return Err(TransitionError::InsufficientAuthority {
303                    incumbent: state.authority.level,
304                    challenger: event.authority.level,
305                });
306            }
307            state.lifecycle = FactLifecycle::Retracted;
308        }
309        FactEventKind::Retrieved { .. } | FactEventKind::UsedInDecision { .. } => {}
310        FactEventKind::ChallengeRejected { .. } => {
311            // Surviving a challenge is earned entrenchment (ADR 0015): record the
312            // challenger at the highest effective authority it ever challenged and lost
313            // at. Bookkeeping only — lifecycle, value, and authority are untouched, and
314            // there is deliberately no authority gate here (the record carries the
315            // *challenger's* authority, which by construction lost to the incumbent's).
316            state
317                .survived_challenges
318                .entry(event.provenance.source.clone())
319                .and_modify(|level| *level = (*level).max(event.authority.level))
320                .or_insert(event.authority.level);
321        }
322    }
323
324    state.updated_at = event.provenance.recorded_at;
325    state.last_event_id = event.event_id.clone();
326    Ok(state)
327}
328
329#[derive(Clone, Debug, Eq, PartialEq)]
330pub enum TransitionError {
331    InvalidEvent(crate::model::ValidationError),
332    MissingInitialAssertion,
333    DuplicateAssertion,
334    FactIdMismatch,
335    FactShapeMismatch,
336    ReinforcementValueMismatch,
337    TerminalStateMutation(FactLifecycle),
338    /// A belief-changing event was rejected because its authority strictly under-ranks
339    /// the incumbent's. Shared by **supersession** and **retraction** (ADR 0008): a
340    /// replacement or a retraction must out-rank or tie the incumbent. (Supersession is
341    /// additionally checked for laundering in `arbitrate`; retraction carries no backing
342    /// fact, so this authority gate is its complete check. Contradiction is *not* gated
343    /// — dissent is always admitted.)
344    InsufficientAuthority {
345        incumbent: AuthorityLevel,
346        challenger: AuthorityLevel,
347    },
348    /// A `fact.contradicted` event targeted a canonical fact. Canonical facts are
349    /// not softly contested; a genuine change must arrive as an equal-authority
350    /// supersession. This is the LFI hard-alarm tier.
351    CanonicalContradiction,
352}
353
354impl fmt::Display for TransitionError {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        match self {
357            Self::InvalidEvent(error) => write!(f, "invalid event: {error}"),
358            Self::MissingInitialAssertion => {
359                f.write_str("fact stream must start with fact.asserted")
360            }
361            Self::DuplicateAssertion => {
362                f.write_str("fact stream cannot assert the same fact twice")
363            }
364            Self::FactIdMismatch => f.write_str("event fact_id does not match current state"),
365            Self::FactShapeMismatch => {
366                f.write_str("event subject or predicate does not match current state")
367            }
368            Self::ReinforcementValueMismatch => {
369                f.write_str("fact.reinforced cannot change the fact value")
370            }
371            Self::TerminalStateMutation(state) => {
372                write!(f, "cannot mutate terminal fact state {state:?}")
373            }
374            Self::InsufficientAuthority {
375                incumbent,
376                challenger,
377            } => write!(
378                f,
379                "insufficient authority: {challenger} may not override or remove an incumbent of {incumbent}"
380            ),
381            Self::CanonicalContradiction => {
382                f.write_str("a canonical fact cannot be contradicted; supersede it with equal authority instead")
383            }
384        }
385    }
386}
387
388impl std::error::Error for TransitionError {}
389
390#[cfg(test)]
391mod tests {
392    use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
393    use crate::model::{
394        Authority, AuthorityLevel, Confidence, ContradictionBasis, Evidence, EvidenceKind,
395        ExpirationReason, FactEvent, FactEventKind, FactValue, Predicate, Provenance,
396        RetractionReason, Subject, SupersessionReason, Ttl,
397    };
398
399    use super::{FactLifecycle, TransitionError, apply_event};
400
401    const ALL_LEVELS: [AuthorityLevel; 5] = [
402        AuthorityLevel::Unknown,
403        AuthorityLevel::Low,
404        AuthorityLevel::Medium,
405        AuthorityLevel::High,
406        AuthorityLevel::Canonical,
407    ];
408
409    fn with_authority(mut event: FactEvent, level: AuthorityLevel) -> FactEvent {
410        event.authority = Authority {
411            level,
412            issuer: None,
413            scope: None,
414        };
415        event
416    }
417
418    fn asserted(level: AuthorityLevel) -> FactEvent {
419        with_authority(
420            base_event(
421                FactEventKind::Asserted,
422                "event:1",
423                Some(FactValue::Text("postgres".to_string())),
424            ),
425            level,
426        )
427    }
428
429    fn supersede(event_id: &str, level: AuthorityLevel) -> FactEvent {
430        with_authority(
431            base_event(
432                FactEventKind::Superseded {
433                    by: fact_id("fact:2"),
434                    reason: SupersessionReason::NewerObservation,
435                },
436                event_id,
437                None,
438            ),
439            level,
440        )
441    }
442
443    fn retract(event_id: &str, level: AuthorityLevel) -> FactEvent {
444        with_authority(
445            base_event(
446                FactEventKind::Retracted {
447                    reason: RetractionReason::UserDeleted,
448                },
449                event_id,
450                None,
451            ),
452            level,
453        )
454    }
455
456    fn expire(event_id: &str, level: AuthorityLevel) -> FactEvent {
457        with_authority(
458            base_event(
459                FactEventKind::Expired {
460                    reason: ExpirationReason::PolicyRetention,
461                },
462                event_id,
463                None,
464            ),
465            level,
466        )
467    }
468
469    fn fact_id(value: &str) -> FactId {
470        FactId::new(value).expect("valid fact id")
471    }
472
473    fn base_event(kind: FactEventKind, event_id: &str, value: Option<FactValue>) -> FactEvent {
474        FactEvent {
475            event_id: FactEventId::new(event_id).expect("valid event id"),
476            fact_id: fact_id("fact:1"),
477            kind,
478            subject: Subject::new("repo", "dent8").expect("valid subject"),
479            predicate: Predicate::new("uses_database").expect("valid predicate"),
480            value,
481            confidence: Confidence::from_millis(900).expect("valid confidence"),
482            authority: Authority::unknown(),
483            ttl: Ttl::Never,
484            provenance: Provenance {
485                source: SourceId::new("source:test").expect("valid source"),
486                actor: ActorId::new("actor:test").expect("valid actor"),
487                tool: Some("unit-test".to_string()),
488                run_id: None,
489                input_digest: None,
490                recorded_at: TimestampMillis::from_unix_millis(1),
491                attestation: None,
492            },
493            evidence: vec![Evidence {
494                id: EvidenceId::new("evidence:1").expect("valid evidence id"),
495                kind: EvidenceKind::UserStatement,
496                locator: "test".to_string(),
497                digest: None,
498                summary: Some("test evidence".to_string()),
499            }],
500            observed_at: None,
501            valid_from: None,
502            valid_to: None,
503        }
504    }
505
506    fn challenge_rejected(event_id: &str, source: &str, level: AuthorityLevel) -> FactEvent {
507        let mut event = with_authority(
508            base_event(
509                FactEventKind::ChallengeRejected {
510                    challenge: crate::model::ChallengeKind::Supersession,
511                    by: Some(fact_id("fact:2")),
512                    rejection: crate::model::ChallengeRejection::InsufficientAuthority,
513                },
514                event_id,
515                None,
516            ),
517            level,
518        );
519        event.provenance.source = SourceId::new(source).expect("valid source");
520        event
521    }
522
523    #[test]
524    fn surviving_challenges_accumulates_challengers_at_their_strongest() {
525        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
526        // Two challenges from the same source at rising strength, one from another.
527        let state = apply_event(
528            Some(state),
529            &challenge_rejected("event:2", "source:attacker", AuthorityLevel::Low),
530        )
531        .expect("recorded");
532        let state = apply_event(
533            Some(state),
534            &challenge_rejected("event:3", "source:attacker", AuthorityLevel::Medium),
535        )
536        .expect("recorded");
537        let state = apply_event(
538            Some(state),
539            &challenge_rejected("event:4", "source:other", AuthorityLevel::Low),
540        )
541        .expect("recorded");
542
543        // Bookkeeping only: the incumbent's belief state is untouched.
544        assert_eq!(state.lifecycle, FactLifecycle::Active);
545        assert_eq!(state.authority.level, AuthorityLevel::High);
546        // Distinct challengers, each at the strongest level they lost at.
547        assert_eq!(state.survived_challenge_count(), 2);
548        assert_eq!(
549            state.survived_challenges_at_or_above(AuthorityLevel::Medium),
550            1,
551            "a Sybil flood of low-authority challenges cannot simulate surviving strong ones"
552        );
553        assert_eq!(
554            state.survived_challenges_at_or_above(AuthorityLevel::Low),
555            2
556        );
557
558        // Earned entrenchment (ADR 0017) sums both Sybil-resistant halves at the queried
559        // level: the lone High asserter (corroboration 1 at Low) plus survived challenges.
560        assert_eq!(
561            state.earned_entrenchment_at_or_above(AuthorityLevel::Low),
562            state.corroboration_at_or_above(AuthorityLevel::Low)
563                + state.survived_challenges_at_or_above(AuthorityLevel::Low),
564        );
565        assert_eq!(
566            state.earned_entrenchment_at_or_above(AuthorityLevel::Low),
567            3, // 1 backer + 2 distinct survived challengers, all at >= Low
568        );
569        assert_eq!(
570            state.earned_entrenchment_at_or_above(AuthorityLevel::Medium),
571            2, // 1 High backer + 1 survived challenge at >= Medium
572        );
573        assert_eq!(
574            state.earned_entrenchment_at_or_above(AuthorityLevel::Canonical),
575            0, // nothing at Canonical
576        );
577    }
578
579    #[test]
580    fn a_challenge_record_is_not_an_initial_event_and_not_a_terminal_mutation() {
581        // Cannot open a stream.
582        assert!(matches!(
583            apply_event(
584                None,
585                &challenge_rejected("event:1", "source:attacker", AuthorityLevel::Low)
586            ),
587            Err(TransitionError::MissingInitialAssertion)
588        ));
589        // Cannot land on a fallen incumbent: a challenge against a terminal fact was not
590        // "survived" — the fact already fell.
591        let state = apply_event(None, &asserted(AuthorityLevel::Low)).expect("asserted");
592        let state = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Low))
593            .expect("superseded");
594        assert!(matches!(
595            apply_event(
596                Some(state),
597                &challenge_rejected("event:3", "source:attacker", AuthorityLevel::Low)
598            ),
599            Err(TransitionError::TerminalStateMutation(
600                FactLifecycle::Superseded
601            ))
602        ));
603    }
604
605    #[test]
606    fn fact_state_without_survived_challenges_field_still_deserializes() {
607        // Projections materialized before ADR 0015 lack the field; serde(default) admits them.
608        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
609        let mut json = serde_json::to_value(&state).expect("serialize");
610        json.as_object_mut()
611            .expect("object")
612            .remove("survived_challenges")
613            .expect("field present in new serialization");
614        let old: super::FactState = serde_json::from_value(json).expect("old shape deserializes");
615        assert_eq!(old.survived_challenge_count(), 0);
616    }
617
618    #[test]
619    fn valid_to_bounds_freshness_like_an_elapsed_ttl() {
620        let mut event = asserted(AuthorityLevel::High);
621        event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
622        event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
623        let state = apply_event(None, &event).expect("asserted");
624
625        // No TTL: expiry is the validity bound alone, inclusive at the boundary.
626        assert_eq!(
627            state.expires_at(),
628            Some(TimestampMillis::from_unix_millis(2_000))
629        );
630        assert!(!state.is_expired_at(TimestampMillis::from_unix_millis(1_999)));
631        assert!(state.is_expired_at(TimestampMillis::from_unix_millis(2_000)));
632
633        // With a TTL, the earliest bound wins in both directions.
634        let mut event = asserted(AuthorityLevel::High);
635        event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
636        event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
637        event.ttl = crate::model::Ttl::DurationMillis(500);
638        let state = apply_event(None, &event).expect("asserted");
639        assert_eq!(
640            state.expires_at(),
641            Some(TimestampMillis::from_unix_millis(1_500)),
642            "a shorter TTL beats a later valid_to"
643        );
644        let mut event = asserted(AuthorityLevel::High);
645        event.valid_from = Some(TimestampMillis::from_unix_millis(1_000));
646        event.valid_to = Some(TimestampMillis::from_unix_millis(1_200));
647        event.ttl = crate::model::Ttl::DurationMillis(500);
648        let state = apply_event(None, &event).expect("asserted");
649        assert_eq!(
650            state.expires_at(),
651            Some(TimestampMillis::from_unix_millis(1_200)),
652            "an earlier valid_to beats a longer TTL"
653        );
654    }
655
656    #[test]
657    fn a_future_valid_from_reads_not_yet_valid_not_fresh() {
658        let mut event = asserted(AuthorityLevel::High);
659        event.valid_from = Some(TimestampMillis::from_unix_millis(2_000));
660        let state = apply_event(None, &event).expect("asserted");
661
662        // Before valid_from: not yet valid, therefore not fresh (ADR 0016 lower bound).
663        assert!(state.is_not_yet_valid_at(TimestampMillis::from_unix_millis(1_000)));
664        assert!(!state.is_fresh_at(TimestampMillis::from_unix_millis(1_000)));
665        // Not *expired* — the upper bound is untouched; the reason is the lower bound.
666        assert!(!state.is_expired_at(TimestampMillis::from_unix_millis(1_000)));
667        // At/after valid_from (no upper bound here): fresh.
668        assert!(!state.is_not_yet_valid_at(TimestampMillis::from_unix_millis(2_000)));
669        assert!(state.is_fresh_at(TimestampMillis::from_unix_millis(2_000)));
670
671        // No valid_from set: never not-yet-valid (unchanged for the common case).
672        let plain = apply_event(None, &asserted(AuthorityLevel::High)).expect("asserted");
673        assert!(!plain.is_not_yet_valid_at(TimestampMillis::from_unix_millis(0)));
674        assert!(plain.is_fresh_at(TimestampMillis::from_unix_millis(0)));
675    }
676
677    #[test]
678    fn an_empty_or_inverted_validity_interval_is_rejected() {
679        let mut event = asserted(AuthorityLevel::High);
680        event.valid_from = Some(TimestampMillis::from_unix_millis(2_000));
681        event.valid_to = Some(TimestampMillis::from_unix_millis(2_000));
682        assert!(matches!(
683            apply_event(None, &event),
684            Err(TransitionError::InvalidEvent(
685                crate::model::ValidationError::InvalidValidityInterval
686            ))
687        ));
688    }
689
690    #[test]
691    fn assertion_creates_active_state() {
692        let event = base_event(
693            FactEventKind::Asserted,
694            "event:1",
695            Some(FactValue::Text("postgres".to_string())),
696        );
697
698        let state = apply_event(None, &event).expect("assertion applies");
699
700        assert_eq!(state.lifecycle, FactLifecycle::Active);
701        assert_eq!(state.evidence_count, 1);
702    }
703
704    #[test]
705    fn duplicate_assertion_is_rejected() {
706        let event = base_event(
707            FactEventKind::Asserted,
708            "event:1",
709            Some(FactValue::Text("postgres".to_string())),
710        );
711        let state = apply_event(None, &event).expect("assertion applies");
712
713        let error = apply_event(Some(state), &event).expect_err("duplicate rejected");
714
715        assert_eq!(error, TransitionError::DuplicateAssertion);
716    }
717
718    #[test]
719    fn contradiction_marks_fact_contested() {
720        let asserted = base_event(
721            FactEventKind::Asserted,
722            "event:1",
723            Some(FactValue::Text("postgres".to_string())),
724        );
725        let state = apply_event(None, &asserted).expect("assertion applies");
726        let contradicted = base_event(
727            FactEventKind::Contradicted {
728                by: fact_id("fact:2"),
729                basis: ContradictionBasis::SamePredicateDifferentValue,
730            },
731            "event:2",
732            None,
733        );
734
735        let state = apply_event(Some(state), &contradicted).expect("contradiction applies");
736
737        assert_eq!(state.lifecycle, FactLifecycle::Contested);
738        assert_eq!(state.contradicted_by, vec![fact_id("fact:2")]);
739    }
740
741    #[test]
742    fn terminal_state_rejects_lifecycle_mutation() {
743        let asserted = base_event(
744            FactEventKind::Asserted,
745            "event:1",
746            Some(FactValue::Text("postgres".to_string())),
747        );
748        let state = apply_event(None, &asserted).expect("assertion applies");
749        let superseded = base_event(
750            FactEventKind::Superseded {
751                by: fact_id("fact:2"),
752                reason: SupersessionReason::NewerObservation,
753            },
754            "event:2",
755            None,
756        );
757        let state = apply_event(Some(state), &superseded).expect("supersession applies");
758        let reinforced = base_event(
759            FactEventKind::Reinforced {
760                by: fact_id("fact:3"),
761            },
762            "event:3",
763            Some(FactValue::Text("postgres".to_string())),
764        );
765
766        let error = apply_event(Some(state), &reinforced).expect_err("terminal mutation rejected");
767
768        assert_eq!(
769            error,
770            TransitionError::TerminalStateMutation(FactLifecycle::Superseded)
771        );
772    }
773
774    #[test]
775    fn retrieval_does_not_change_lifecycle() {
776        let asserted = base_event(
777            FactEventKind::Asserted,
778            "event:1",
779            Some(FactValue::Text("postgres".to_string())),
780        );
781        let state = apply_event(None, &asserted).expect("assertion applies");
782        let retrieved = base_event(
783            FactEventKind::Retrieved {
784                purpose: "context".to_string(),
785            },
786            "event:2",
787            None,
788        );
789
790        let state = apply_event(Some(state), &retrieved).expect("retrieval applies");
791
792        assert_eq!(state.lifecycle, FactLifecycle::Active);
793    }
794
795    #[test]
796    fn lower_authority_supersession_is_rejected() {
797        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
798
799        let error = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Low))
800            .expect_err("low-authority supersession rejected");
801
802        assert_eq!(
803            error,
804            TransitionError::InsufficientAuthority {
805                incumbent: AuthorityLevel::High,
806                challenger: AuthorityLevel::Low,
807            }
808        );
809    }
810
811    #[test]
812    fn equal_authority_supersession_succeeds() {
813        let state =
814            apply_event(None, &asserted(AuthorityLevel::Medium)).expect("assertion applies");
815
816        let state = apply_event(Some(state), &supersede("event:2", AuthorityLevel::Medium))
817            .expect("equal-authority supersession applies");
818
819        assert_eq!(state.lifecycle, FactLifecycle::Superseded);
820    }
821
822    #[test]
823    fn higher_authority_supersession_succeeds() {
824        let state = apply_event(None, &asserted(AuthorityLevel::Low)).expect("assertion applies");
825
826        let state = apply_event(
827            Some(state),
828            &supersede("event:2", AuthorityLevel::Canonical),
829        )
830        .expect("higher-authority supersession applies");
831
832        assert_eq!(state.lifecycle, FactLifecycle::Superseded);
833    }
834
835    #[test]
836    fn contradicting_a_canonical_fact_hard_alarms() {
837        let state =
838            apply_event(None, &asserted(AuthorityLevel::Canonical)).expect("assertion applies");
839        let contradicted = base_event(
840            FactEventKind::Contradicted {
841                by: fact_id("fact:2"),
842                basis: ContradictionBasis::SamePredicateDifferentValue,
843            },
844            "event:2",
845            None,
846        );
847
848        let error = apply_event(Some(state), &contradicted)
849            .expect_err("canonical contradiction hard-alarms");
850
851        assert_eq!(error, TransitionError::CanonicalContradiction);
852    }
853
854    #[test]
855    fn contradicting_a_non_canonical_fact_still_contests() {
856        let state = apply_event(None, &asserted(AuthorityLevel::High)).expect("assertion applies");
857        let contradicted = base_event(
858            FactEventKind::Contradicted {
859                by: fact_id("fact:2"),
860                basis: ContradictionBasis::SamePredicateDifferentValue,
861            },
862            "event:2",
863            None,
864        );
865
866        let state =
867            apply_event(Some(state), &contradicted).expect("non-canonical contradiction applies");
868
869        assert_eq!(state.lifecycle, FactLifecycle::Contested);
870    }
871
872    /// Exhaustive bounded proof over the finite `AuthorityLevel` lattice: for every
873    /// (incumbent, challenger) pair, a supersession is accepted iff the challenger does
874    /// not under-rank the incumbent; and once accepted, the fact is terminal and
875    /// cannot be resurrected by any later event — even a canonical one. This is the
876    /// runnable form of the rank-1 "verified non-resurrection" invariant; the
877    /// `#[cfg(kani)]` harness in `proofs` checks the same property symbolically.
878    #[test]
879    fn authority_monotone_supersession_and_non_resurrection() {
880        for incumbent in ALL_LEVELS {
881            for challenger in ALL_LEVELS {
882                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
883                let result = apply_event(Some(state), &supersede("event:2", challenger));
884
885                if challenger < incumbent {
886                    assert_eq!(
887                        result,
888                        Err(TransitionError::InsufficientAuthority {
889                            incumbent,
890                            challenger,
891                        }),
892                        "challenger {challenger:?} should not supersede incumbent {incumbent:?}",
893                    );
894                    continue;
895                }
896
897                let superseded = result.expect("non-under-ranking supersession applies");
898                assert!(
899                    superseded.lifecycle.is_terminal(),
900                    "supersession should make the fact terminal",
901                );
902
903                // Non-resurrection: no later event, even a canonical supersession,
904                // returns a terminal fact to an active/believed state.
905                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
906                let error = apply_event(Some(superseded), &resurrect)
907                    .expect_err("terminal fact cannot be resurrected");
908                assert_eq!(
909                    error,
910                    TransitionError::TerminalStateMutation(FactLifecycle::Superseded)
911                );
912            }
913        }
914    }
915
916    /// The retraction counterpart of the supersession lattice (ADR 0008): a `Retracted`
917    /// event is accepted iff the retractor does not under-rank the incumbent, and once
918    /// accepted the fact is terminally `Retracted` and cannot be resurrected. Retraction
919    /// removes a belief, so it is authority-gated like supersession — unlike a
920    /// `Contradicted` event, which is dissent and is admitted at any authority.
921    #[test]
922    fn authority_monotone_retraction_and_non_resurrection() {
923        for incumbent in ALL_LEVELS {
924            for challenger in ALL_LEVELS {
925                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
926                let result = apply_event(Some(state), &retract("event:2", challenger));
927
928                if challenger < incumbent {
929                    assert_eq!(
930                        result,
931                        Err(TransitionError::InsufficientAuthority {
932                            incumbent,
933                            challenger,
934                        }),
935                        "retractor {challenger:?} should not retract incumbent {incumbent:?}",
936                    );
937                    continue;
938                }
939
940                let retracted = result.expect("non-under-ranking retraction applies");
941                assert_eq!(retracted.lifecycle, FactLifecycle::Retracted);
942                assert!(retracted.lifecycle.is_terminal());
943
944                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
945                let error = apply_event(Some(retracted), &resurrect)
946                    .expect_err("terminal fact cannot be resurrected");
947                assert_eq!(
948                    error,
949                    TransitionError::TerminalStateMutation(FactLifecycle::Retracted)
950                );
951            }
952        }
953    }
954
955    /// Explicit expiration is also a terminal close. TTL staleness is read-time and needs no
956    /// actor authority, but a `fact.expired` event changes the durable lifecycle, so it must
957    /// not under-rank the incumbent.
958    #[test]
959    fn authority_monotone_expiration_and_non_resurrection() {
960        for incumbent in ALL_LEVELS {
961            for challenger in ALL_LEVELS {
962                let state = apply_event(None, &asserted(incumbent)).expect("assertion applies");
963                let result = apply_event(Some(state), &expire("event:2", challenger));
964
965                if challenger < incumbent {
966                    assert_eq!(
967                        result,
968                        Err(TransitionError::InsufficientAuthority {
969                            incumbent,
970                            challenger,
971                        }),
972                        "expirer {challenger:?} should not expire incumbent {incumbent:?}",
973                    );
974                    continue;
975                }
976
977                let expired = result.expect("non-under-ranking expiration applies");
978                assert_eq!(expired.lifecycle, FactLifecycle::Expired);
979                assert!(expired.lifecycle.is_terminal());
980
981                let resurrect = supersede("event:3", AuthorityLevel::Canonical);
982                let error = apply_event(Some(expired), &resurrect)
983                    .expect_err("terminal fact cannot be resurrected");
984                assert_eq!(
985                    error,
986                    TransitionError::TerminalStateMutation(FactLifecycle::Expired)
987                );
988            }
989        }
990    }
991}
992
993/// Rank-1 "verified non-resurrection" harness. Bounded model check of the
994/// authority-weighted supersession gate with Kani (`cargo kani`). Excluded from
995/// normal builds via `#[cfg(kani)]`; see `docs/formal-verification.md` and
996/// `docs/research/novelty.md`. Authority levels are symbolic (`kani::any`),
997/// everything else is fixed, keeping the proof tractable.
998#[cfg(kani)]
999mod proofs {
1000    use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
1001    use crate::model::{
1002        Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, ExpirationReason, FactEvent,
1003        FactEventKind, FactValue, Predicate, Provenance, Subject, SupersessionReason, Ttl,
1004    };
1005
1006    use super::apply_event;
1007
1008    fn level_from(n: u8) -> AuthorityLevel {
1009        match n {
1010            0 => AuthorityLevel::Unknown,
1011            1 => AuthorityLevel::Low,
1012            2 => AuthorityLevel::Medium,
1013            3 => AuthorityLevel::High,
1014            _ => AuthorityLevel::Canonical,
1015        }
1016    }
1017
1018    fn event(
1019        kind: FactEventKind,
1020        event_id: &str,
1021        value: Option<FactValue>,
1022        level: AuthorityLevel,
1023    ) -> FactEvent {
1024        FactEvent {
1025            event_id: FactEventId::new(event_id).unwrap(),
1026            fact_id: FactId::new("fact:1").unwrap(),
1027            kind,
1028            subject: Subject::new("repo", "dent8").unwrap(),
1029            predicate: Predicate::new("uses_database").unwrap(),
1030            value,
1031            confidence: Confidence::from_millis(900).unwrap(),
1032            authority: Authority {
1033                level,
1034                issuer: None,
1035                scope: None,
1036            },
1037            ttl: Ttl::Never,
1038            provenance: Provenance {
1039                source: SourceId::new("source:test").unwrap(),
1040                actor: ActorId::new("actor:test").unwrap(),
1041                tool: None,
1042                run_id: None,
1043                input_digest: None,
1044                recorded_at: TimestampMillis::from_unix_millis(1),
1045                attestation: None,
1046            },
1047            evidence: vec![Evidence {
1048                id: EvidenceId::new("evidence:1").unwrap(),
1049                kind: EvidenceKind::UserStatement,
1050                locator: "x".to_string(),
1051                digest: None,
1052                summary: None,
1053            }],
1054            observed_at: None,
1055            valid_from: None,
1056            valid_to: None,
1057        }
1058    }
1059
1060    #[kani::proof]
1061    fn supersession_is_authority_monotone_and_non_resurrecting() {
1062        let a: u8 = kani::any();
1063        let b: u8 = kani::any();
1064        kani::assume(a < 5);
1065        kani::assume(b < 5);
1066        let incumbent = level_from(a);
1067        let challenger = level_from(b);
1068
1069        let asserted = event(
1070            FactEventKind::Asserted,
1071            "event:1",
1072            Some(FactValue::Text("postgres".to_string())),
1073            incumbent,
1074        );
1075        let state = apply_event(None, &asserted).unwrap();
1076
1077        let superseded = event(
1078            FactEventKind::Superseded {
1079                by: FactId::new("fact:2").unwrap(),
1080                reason: SupersessionReason::NewerObservation,
1081            },
1082            "event:2",
1083            None,
1084            challenger,
1085        );
1086
1087        match apply_event(Some(state), &superseded) {
1088            Ok(next) => {
1089                // Accepted only when the challenger does not under-rank the incumbent,
1090                // and the result is terminal.
1091                assert!(challenger >= incumbent);
1092                assert!(next.lifecycle.is_terminal());
1093
1094                // Non-resurrection: even a canonical event cannot re-activate it.
1095                let resurrect = event(
1096                    FactEventKind::Superseded {
1097                        by: FactId::new("fact:3").unwrap(),
1098                        reason: SupersessionReason::NewerObservation,
1099                    },
1100                    "event:3",
1101                    None,
1102                    AuthorityLevel::Canonical,
1103                );
1104                assert!(apply_event(Some(next), &resurrect).is_err());
1105            }
1106            Err(_) => {
1107                // Rejected only when the challenger strictly under-ranks the incumbent.
1108                assert!(challenger < incumbent);
1109            }
1110        }
1111    }
1112
1113    #[kani::proof]
1114    fn retraction_is_authority_monotone_and_non_resurrecting() {
1115        use crate::model::RetractionReason;
1116
1117        let a: u8 = kani::any();
1118        let b: u8 = kani::any();
1119        kani::assume(a < 5);
1120        kani::assume(b < 5);
1121        let incumbent = level_from(a);
1122        let challenger = level_from(b);
1123
1124        let asserted = event(
1125            FactEventKind::Asserted,
1126            "event:1",
1127            Some(FactValue::Text("postgres".to_string())),
1128            incumbent,
1129        );
1130        let state = apply_event(None, &asserted).unwrap();
1131
1132        let retracted = event(
1133            FactEventKind::Retracted {
1134                reason: RetractionReason::UserDeleted,
1135            },
1136            "event:2",
1137            None,
1138            challenger,
1139        );
1140
1141        match apply_event(Some(state), &retracted) {
1142            Ok(next) => {
1143                assert!(challenger >= incumbent);
1144                assert!(next.lifecycle.is_terminal());
1145
1146                let resurrect = event(
1147                    FactEventKind::Superseded {
1148                        by: FactId::new("fact:3").unwrap(),
1149                        reason: SupersessionReason::NewerObservation,
1150                    },
1151                    "event:3",
1152                    None,
1153                    AuthorityLevel::Canonical,
1154                );
1155                assert!(apply_event(Some(next), &resurrect).is_err());
1156            }
1157            Err(_) => {
1158                assert!(challenger < incumbent);
1159            }
1160        }
1161    }
1162
1163    #[kani::proof]
1164    fn expiration_is_authority_monotone_and_non_resurrecting() {
1165        let a: u8 = kani::any();
1166        let b: u8 = kani::any();
1167        kani::assume(a < 5);
1168        kani::assume(b < 5);
1169        let incumbent = level_from(a);
1170        let challenger = level_from(b);
1171
1172        let asserted = event(
1173            FactEventKind::Asserted,
1174            "event:1",
1175            Some(FactValue::Text("postgres".to_string())),
1176            incumbent,
1177        );
1178        let state = apply_event(None, &asserted).unwrap();
1179
1180        let expired = event(
1181            FactEventKind::Expired {
1182                reason: ExpirationReason::PolicyRetention,
1183            },
1184            "event:2",
1185            None,
1186            challenger,
1187        );
1188
1189        match apply_event(Some(state), &expired) {
1190            Ok(next) => {
1191                assert!(challenger >= incumbent);
1192                assert!(next.lifecycle.is_terminal());
1193
1194                let resurrect = event(
1195                    FactEventKind::Superseded {
1196                        by: FactId::new("fact:3").unwrap(),
1197                        reason: SupersessionReason::NewerObservation,
1198                    },
1199                    "event:3",
1200                    None,
1201                    AuthorityLevel::Canonical,
1202                );
1203                assert!(apply_event(Some(next), &resurrect).is_err());
1204            }
1205            Err(_) => {
1206                assert!(challenger < incumbent);
1207            }
1208        }
1209    }
1210}