Skip to main content

termivar_scanner/
experience.rs

1//! Deterministic experience derived from verification outcomes.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The store records immutable outcomes and turns repeated, subject-scoped,
13//! suppression-eligible negatives into explainable action recommendations.
14//! Target blocks, policy blocks, operational failures, and inconclusive
15//! verification remain visible without being treated as proof that an action
16//! has no utility. The store does not execute an action, mutate knowledge, or
17//! make planner and adaptive policy depend on its internal representation.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Deserializer, Serialize};
22use termivar_core::{EntityId, Outcome, OutcomeStatus, VerificationStage};
23use thiserror::Error;
24
25/// Validation and consistency errors raised by the experience store.
26#[derive(Debug, Clone, PartialEq, Eq, Error)]
27#[non_exhaustive]
28pub enum ExperienceStoreError {
29    /// A suppression threshold of zero would suppress an action before it ran.
30    #[error("consecutive suppression-eligible failure limit must be greater than zero")]
31    ZeroFailureLimit,
32
33    /// The same subject, action, case, and stage identified a different
34    /// outcome or disposition.
35    #[error(
36        "experience identity conflict for subject {subject}, action {action_id}, case {case_id}, stage {stage:?}"
37    )]
38    IdentityConflict {
39        /// Subject whose history contained the conflict.
40        subject: EntityId,
41        /// Action whose history contained the conflict.
42        action_id: String,
43        /// Verification case whose result changed.
44        case_id: String,
45        /// Verification stage whose result changed.
46        stage: VerificationStage,
47    },
48
49    /// The monotonically increasing observation sequence overflowed.
50    #[error("experience observation sequence overflowed")]
51    SequenceOverflow,
52
53    /// Persisted experience records were not contiguous and ordered.
54    #[error("invalid persisted experience sequence: expected {expected}, found {actual}")]
55    InvalidSequence {
56        /// Required sequence at this position.
57        expected: u64,
58        /// Sequence found in the archive.
59        actual: u64,
60    },
61
62    /// Persisted next-sequence state did not follow the final record.
63    #[error("invalid next experience sequence: expected {expected}, found {actual}")]
64    InvalidNextSequence {
65        /// Required next sequence.
66        expected: u64,
67        /// Persisted next sequence.
68        actual: u64,
69    },
70
71    /// Persisted state contained the same immutable observation more than once.
72    #[error("persisted experience contains a duplicate observation")]
73    DuplicateObservation,
74
75    /// A caller attached a disposition that contradicts the verifier outcome.
76    #[error("experience disposition {disposition:?} is incompatible with outcome {status:?}")]
77    IncompatibleDisposition {
78        /// Verifier status carried by the outcome.
79        status: OutcomeStatus,
80        /// Experience classification rejected by the store.
81        disposition: ExperienceDisposition,
82    },
83}
84
85/// Result of recording an outcome identity.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88#[non_exhaustive]
89pub enum ExperienceWrite {
90    /// A previously unseen outcome was appended.
91    Inserted,
92    /// The exact immutable outcome was already present.
93    Unchanged,
94}
95
96/// Reason an observed attempt should or should not influence future planning.
97///
98/// This classification deliberately lives outside [`OutcomeStatus`]. Target,
99/// host-policy, transport, and executor failures are operational facts rather
100/// than verifier conclusions. Only [`Self::VerificationRejected`] and
101/// [`Self::ConfirmedNegative`] contribute to the suppression streak; transient
102/// or inconclusive dispositions remain neutral.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105#[non_exhaustive]
106pub enum ExperienceDisposition {
107    /// The verifier reported a successful outcome for the action.
108    ///
109    /// This resets the action-scoped negative suppression streak. It does not,
110    /// by itself, prove that the associated hypothesis was authorized to
111    /// transition or that its state changed; consult the knowledge base and the
112    /// complete verification report for authoritative hypothesis state.
113    ConfirmedPositive,
114    /// The action does not apply to the observed subject.
115    NotApplicable,
116    /// The target denied, rate-limited, or otherwise blocked the attempt.
117    BlockedByTarget,
118    /// Host authorization or safety policy refused the attempt.
119    BlockedByPolicy,
120    /// Network transport failed before verification could conclude.
121    TransportFailure,
122    /// The selected executor failed independently of the target response.
123    ExecutorFailure,
124    /// A verifier rejected the tested hypothesis.
125    VerificationRejected,
126    /// Available evidence could not support a deterministic conclusion.
127    VerificationInconclusive,
128    /// A trusted active check explicitly confirmed a negative result.
129    ConfirmedNegative,
130}
131
132impl ExperienceDisposition {
133    /// Infers the conservative disposition used by [`ExperienceStore::observe`].
134    ///
135    /// `FalsePositive` remains `VerificationRejected`: an outcome alone does
136    /// not prove that an audited active negative check was performed.
137    /// `Success` remains the action-level [`Self::ConfirmedPositive`] even when
138    /// case policy suppresses a hypothesis transition.
139    pub fn from_outcome(outcome: &Outcome) -> Self {
140        match outcome.status() {
141            OutcomeStatus::Success => Self::ConfirmedPositive,
142            OutcomeStatus::Blocked => Self::BlockedByTarget,
143            OutcomeStatus::Unknown | OutcomeStatus::NeedsReview => Self::VerificationInconclusive,
144            OutcomeStatus::FalsePositive => Self::VerificationRejected,
145            OutcomeStatus::ConfirmedNegative => Self::ConfirmedNegative,
146            _ => Self::VerificationInconclusive,
147        }
148    }
149
150    const fn accepts(self, status: OutcomeStatus) -> bool {
151        match self {
152            Self::ConfirmedPositive => matches!(status, OutcomeStatus::Success),
153            Self::VerificationRejected => matches!(status, OutcomeStatus::FalsePositive),
154            Self::ConfirmedNegative => matches!(status, OutcomeStatus::ConfirmedNegative),
155            Self::BlockedByTarget => matches!(status, OutcomeStatus::Blocked),
156            Self::NotApplicable
157            | Self::BlockedByPolicy
158            | Self::TransportFailure
159            | Self::ExecutorFailure
160            | Self::VerificationInconclusive => {
161                matches!(status, OutcomeStatus::Unknown | OutcomeStatus::NeedsReview)
162            },
163        }
164    }
165
166    const fn suppression_effect(self) -> SuppressionEffect {
167        match self {
168            Self::ConfirmedPositive => SuppressionEffect::Reset,
169            Self::VerificationRejected | Self::ConfirmedNegative => SuppressionEffect::Increment,
170            Self::NotApplicable
171            | Self::BlockedByTarget
172            | Self::BlockedByPolicy
173            | Self::TransportFailure
174            | Self::ExecutorFailure
175            | Self::VerificationInconclusive => SuppressionEffect::Neutral,
176        }
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181enum SuppressionEffect {
182    Increment,
183    Reset,
184    Neutral,
185}
186
187/// Stable learning policy applied when assessing one action.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189pub struct ExperiencePolicy {
190    consecutive_failure_limit: u16,
191}
192
193impl ExperiencePolicy {
194    /// Creates a policy that suppresses an action after this many consecutive
195    /// suppression-eligible verified negatives.
196    pub fn new(consecutive_failure_limit: u16) -> Result<Self, ExperienceStoreError> {
197        if consecutive_failure_limit == 0 {
198            return Err(ExperienceStoreError::ZeroFailureLimit);
199        }
200        Ok(Self {
201            consecutive_failure_limit,
202        })
203    }
204
205    /// Returns the consecutive suppression-eligible negative threshold.
206    ///
207    /// This compatibility name is retained for the existing source and wire
208    /// contract. Prefer [`Self::consecutive_suppressible_failure_limit`] in new
209    /// integrations.
210    pub fn consecutive_failure_limit(self) -> u16 {
211        self.consecutive_failure_limit
212    }
213
214    /// Returns the consecutive suppression-eligible negative threshold.
215    pub fn consecutive_suppressible_failure_limit(self) -> u16 {
216        self.consecutive_failure_limit
217    }
218}
219
220impl Default for ExperiencePolicy {
221    fn default() -> Self {
222        Self {
223            consecutive_failure_limit: 10,
224        }
225    }
226}
227
228impl<'de> Deserialize<'de> for ExperiencePolicy {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: Deserializer<'de>,
232    {
233        #[derive(Deserialize)]
234        struct WirePolicy {
235            consecutive_failure_limit: u16,
236        }
237
238        let wire = WirePolicy::deserialize(deserializer)?;
239        Self::new(wire.consecutive_failure_limit).map_err(serde::de::Error::custom)
240    }
241}
242
243/// Recommendation derived from completed attempts for one subject and action.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246#[non_exhaustive]
247pub enum ExperienceRecommendation {
248    /// No completed attempt exists, so the action may be explored.
249    Explore,
250    /// History is insufficient to suppress another attempt.
251    Continue,
252    /// Repeating the action has no current utility for this subject.
253    Suppress,
254}
255
256/// Immutable record stored in global observation order.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
258pub struct ExperienceRecord {
259    sequence: u64,
260    outcome: Outcome,
261    disposition: ExperienceDisposition,
262}
263
264impl ExperienceRecord {
265    /// Returns the global zero-based observation sequence.
266    pub fn sequence(&self) -> u64 {
267        self.sequence
268    }
269
270    /// Returns the immutable verification outcome.
271    pub fn outcome(&self) -> &Outcome {
272        &self.outcome
273    }
274
275    /// Returns why this result may or may not influence future planning.
276    pub fn disposition(&self) -> ExperienceDisposition {
277        self.disposition
278    }
279}
280
281impl<'de> Deserialize<'de> for ExperienceRecord {
282    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283    where
284        D: Deserializer<'de>,
285    {
286        #[derive(Deserialize)]
287        struct WireRecord {
288            sequence: u64,
289            outcome: Outcome,
290            #[serde(default)]
291            disposition: Option<ExperienceDisposition>,
292        }
293
294        let wire = WireRecord::deserialize(deserializer)?;
295        let disposition = wire
296            .disposition
297            .unwrap_or_else(|| ExperienceDisposition::from_outcome(&wire.outcome));
298        validate_disposition(&wire.outcome, disposition).map_err(serde::de::Error::custom)?;
299        Ok(Self {
300            sequence: wire.sequence,
301            outcome: wire.outcome,
302            disposition,
303        })
304    }
305}
306
307/// Explainable assessment of action history for one subject.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct ExperienceAssessment {
310    subject: EntityId,
311    action_id: String,
312    completed_attempts: usize,
313    consecutive_failures: u16,
314    last_status: Option<OutcomeStatus>,
315    last_stage: Option<VerificationStage>,
316    last_disposition: Option<ExperienceDisposition>,
317    recommendation: ExperienceRecommendation,
318    rationale: String,
319}
320
321impl ExperienceAssessment {
322    /// Returns the subject whose history was assessed.
323    pub fn subject(&self) -> &EntityId {
324        &self.subject
325    }
326
327    /// Returns the assessed action identity.
328    pub fn action_id(&self) -> &str {
329        &self.action_id
330    }
331
332    /// Returns the number of distinct cases with completed attempts.
333    pub fn completed_attempts(&self) -> usize {
334        self.completed_attempts
335    }
336
337    /// Returns suppression-eligible negatives since the most recent success.
338    ///
339    /// This compatibility name no longer counts target blocks, policy blocks,
340    /// operational failures, or inconclusive verification.
341    pub fn consecutive_failures(&self) -> u16 {
342        self.consecutive_failures
343    }
344
345    /// Returns suppression-eligible verified negatives since the most recent success.
346    pub fn consecutive_suppressible_failures(&self) -> u16 {
347        self.consecutive_failures
348    }
349
350    /// Returns the latest completed status, if one exists.
351    pub fn last_status(&self) -> Option<OutcomeStatus> {
352        self.last_status
353    }
354
355    /// Returns the latest completed verification stage, if one exists.
356    pub fn last_stage(&self) -> Option<VerificationStage> {
357        self.last_stage
358    }
359
360    /// Returns the latest completed attempt classification, if one exists.
361    pub fn last_disposition(&self) -> Option<ExperienceDisposition> {
362        self.last_disposition
363    }
364
365    /// Returns the deterministic learning recommendation.
366    pub fn recommendation(&self) -> ExperienceRecommendation {
367        self.recommendation
368    }
369
370    /// Returns the human-readable recommendation explanation.
371    pub fn rationale(&self) -> &str {
372        &self.rationale
373    }
374
375    /// Returns whether policy recommends excluding the action.
376    pub fn is_suppressed(&self) -> bool {
377        self.recommendation == ExperienceRecommendation::Suppress
378    }
379}
380
381/// Replayable, target-scoped outcome experience.
382///
383/// Observation order is represented by a monotonic integer rather than wall
384/// clock time. Recording an identical outcome is idempotent. Passive
385/// inconclusive results do not count as completed attempts; a later active
386/// result for the same case replaces them during assessment.
387///
388/// This store is action-outcome learning history. It does not receive or
389/// preserve [`crate::VerificationCase`] transition authorization, so an
390/// [`ExperienceDisposition::ConfirmedPositive`] is not evidence that a
391/// hypothesis state changed. The [`KnowledgeBase`](crate::KnowledgeBase) and
392/// complete [`VerificationReport`](crate::VerificationReport) remain
393/// authoritative for hypothesis state and transition audit.
394///
395/// # Example
396///
397/// ```rust
398/// use termivar_scanner::{ExperiencePolicy, ExperienceStore};
399///
400/// let store = ExperienceStore::new();
401/// assert_eq!(store.len(), 0);
402/// assert_eq!(ExperiencePolicy::default().consecutive_failure_limit(), 10);
403/// ```
404#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
405pub struct ExperienceStore {
406    next_sequence: u64,
407    records: Vec<ExperienceRecord>,
408}
409
410impl ExperienceStore {
411    /// Creates an empty store.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Records one immutable outcome in deterministic call order.
417    pub fn observe(&mut self, outcome: Outcome) -> Result<ExperienceWrite, ExperienceStoreError> {
418        let disposition = ExperienceDisposition::from_outcome(&outcome);
419        self.observe_with_disposition(outcome, disposition)
420    }
421
422    /// Records an outcome with an explicit, status-compatible disposition.
423    ///
424    /// Callers should use this only when structured policy or an audited
425    /// verifier can distinguish the conservative classification produced by
426    /// [`Self::observe`]. Operational failures should not be converted into a
427    /// synthetic successful or negative verifier result.
428    pub fn observe_with_disposition(
429        &mut self,
430        outcome: Outcome,
431        disposition: ExperienceDisposition,
432    ) -> Result<ExperienceWrite, ExperienceStoreError> {
433        validate_disposition(&outcome, disposition)?;
434        if let Some(existing) = self
435            .records
436            .iter()
437            .find(|record| same_identity(record.outcome(), &outcome))
438        {
439            return if existing.outcome == outcome && existing.disposition == disposition {
440                Ok(ExperienceWrite::Unchanged)
441            } else {
442                Err(ExperienceStoreError::IdentityConflict {
443                    subject: outcome.subject().clone(),
444                    action_id: outcome.action_id().to_owned(),
445                    case_id: outcome.case_id().to_owned(),
446                    stage: outcome.stage(),
447                })
448            };
449        }
450
451        let following = self
452            .next_sequence
453            .checked_add(1)
454            .ok_or(ExperienceStoreError::SequenceOverflow)?;
455        self.records.push(ExperienceRecord {
456            sequence: self.next_sequence,
457            outcome,
458            disposition,
459        });
460        self.next_sequence = following;
461        Ok(ExperienceWrite::Inserted)
462    }
463
464    /// Returns the number of unique stage outcomes.
465    pub fn len(&self) -> usize {
466        self.records.len()
467    }
468
469    /// Returns whether no outcomes have been observed.
470    pub fn is_empty(&self) -> bool {
471        self.records.is_empty()
472    }
473
474    /// Returns all records in stable global observation order.
475    pub fn records(&self) -> &[ExperienceRecord] {
476        &self.records
477    }
478
479    /// Returns records for one subject and action in observation order.
480    pub fn history<'a>(
481        &'a self,
482        subject: &'a EntityId,
483        action_id: &'a str,
484    ) -> impl Iterator<Item = &'a ExperienceRecord> + 'a {
485        self.records.iter().filter(move |record| {
486            record.outcome.subject() == subject && record.outcome.action_id() == action_id
487        })
488    }
489
490    /// Assesses the latest completed result for each distinct verification case.
491    pub fn assess(
492        &self,
493        subject: &EntityId,
494        action_id: &str,
495        policy: ExperiencePolicy,
496    ) -> ExperienceAssessment {
497        let mut latest_by_case = BTreeMap::<&str, &ExperienceRecord>::new();
498        for record in self.history(subject, action_id) {
499            latest_by_case
500                .entry(record.outcome.case_id())
501                .and_modify(|existing| {
502                    if existing.outcome.stage() == VerificationStage::Passive
503                        && record.outcome.stage() == VerificationStage::Active
504                    {
505                        *existing = record;
506                    }
507                })
508                .or_insert(record);
509        }
510        let mut completed: Vec<_> = latest_by_case
511            .values()
512            .copied()
513            .filter(|record| is_completed_attempt(record.outcome()))
514            .collect();
515        completed.sort_by_key(|record| record.sequence);
516
517        let mut consecutive_failures = 0_u16;
518        for record in completed.iter().rev() {
519            match record.disposition.suppression_effect() {
520                SuppressionEffect::Increment => {
521                    consecutive_failures = consecutive_failures.saturating_add(1);
522                },
523                SuppressionEffect::Reset => break,
524                SuppressionEffect::Neutral => {},
525            }
526        }
527
528        let last = completed.last().map(|record| record.outcome());
529        let last_disposition = completed.last().map(|record| record.disposition());
530        let recommendation = if consecutive_failures >= policy.consecutive_failure_limit {
531            ExperienceRecommendation::Suppress
532        } else if completed.is_empty() {
533            ExperienceRecommendation::Explore
534        } else {
535            ExperienceRecommendation::Continue
536        };
537        let rationale = match recommendation {
538            ExperienceRecommendation::Explore => {
539                "no completed experience exists for this subject and action".to_owned()
540            },
541            ExperienceRecommendation::Continue => format!(
542                "{consecutive_failures} suppression-eligible verified negatives remain below the policy limit of {}",
543                policy.consecutive_failure_limit
544            ),
545            ExperienceRecommendation::Suppress => format!(
546                "{consecutive_failures} suppression-eligible verified negatives reached the policy limit of {}",
547                policy.consecutive_failure_limit
548            ),
549        };
550
551        ExperienceAssessment {
552            subject: subject.clone(),
553            action_id: action_id.to_owned(),
554            completed_attempts: completed.len(),
555            consecutive_failures,
556            last_status: last.map(Outcome::status),
557            last_stage: last.map(Outcome::stage),
558            last_disposition,
559            recommendation,
560            rationale,
561        }
562    }
563
564    /// Returns policy-suppressed action IDs for one subject in stable order.
565    pub fn suppressed_actions(
566        &self,
567        subject: &EntityId,
568        policy: ExperiencePolicy,
569    ) -> BTreeSet<String> {
570        let action_ids: BTreeSet<_> = self
571            .records
572            .iter()
573            .filter(|record| record.outcome.subject() == subject)
574            .map(|record| record.outcome.action_id().to_owned())
575            .collect();
576        action_ids
577            .into_iter()
578            .filter(|action_id| self.assess(subject, action_id, policy).is_suppressed())
579            .collect()
580    }
581}
582
583impl<'de> Deserialize<'de> for ExperienceStore {
584    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
585    where
586        D: Deserializer<'de>,
587    {
588        #[derive(Deserialize)]
589        struct WireStore {
590            next_sequence: u64,
591            records: Vec<ExperienceRecord>,
592        }
593
594        let wire = WireStore::deserialize(deserializer)?;
595        for (expected, record) in wire.records.iter().enumerate() {
596            let expected = u64::try_from(expected).map_err(serde::de::Error::custom)?;
597            if record.sequence != expected {
598                return Err(serde::de::Error::custom(
599                    ExperienceStoreError::InvalidSequence {
600                        expected,
601                        actual: record.sequence,
602                    },
603                ));
604            }
605        }
606        let expected = u64::try_from(wire.records.len()).map_err(serde::de::Error::custom)?;
607        if wire.next_sequence != expected {
608            return Err(serde::de::Error::custom(
609                ExperienceStoreError::InvalidNextSequence {
610                    expected,
611                    actual: wire.next_sequence,
612                },
613            ));
614        }
615
616        let mut store = Self::new();
617        for record in wire.records {
618            let write = store
619                .observe_with_disposition(record.outcome, record.disposition)
620                .map_err(serde::de::Error::custom)?;
621            if write == ExperienceWrite::Unchanged {
622                return Err(serde::de::Error::custom(
623                    ExperienceStoreError::DuplicateObservation,
624                ));
625            }
626        }
627        Ok(store)
628    }
629}
630
631fn same_identity(left: &Outcome, right: &Outcome) -> bool {
632    left.subject() == right.subject()
633        && left.action_id() == right.action_id()
634        && left.case_id() == right.case_id()
635        && left.stage() == right.stage()
636}
637
638fn validate_disposition(
639    outcome: &Outcome,
640    disposition: ExperienceDisposition,
641) -> Result<(), ExperienceStoreError> {
642    if disposition.accepts(outcome.status()) {
643        Ok(())
644    } else {
645        Err(ExperienceStoreError::IncompatibleDisposition {
646            status: outcome.status(),
647            disposition,
648        })
649    }
650}
651
652fn is_completed_attempt(outcome: &Outcome) -> bool {
653    match outcome.status() {
654        OutcomeStatus::Success
655        | OutcomeStatus::Blocked
656        | OutcomeStatus::FalsePositive
657        | OutcomeStatus::ConfirmedNegative => true,
658        OutcomeStatus::Unknown | OutcomeStatus::NeedsReview => {
659            outcome.stage() == VerificationStage::Active
660        },
661        _ => false,
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use std::collections::BTreeSet;
669    use termivar_core::{EvidenceId, Probability};
670
671    fn subject(value: &str) -> EntityId {
672        EntityId::new(value).unwrap()
673    }
674
675    fn verified(
676        case_number: usize,
677        subject: EntityId,
678        action_id: &str,
679        stage: VerificationStage,
680        status: OutcomeStatus,
681    ) -> Outcome {
682        if status == OutcomeStatus::Unknown {
683            return Outcome::unknown(
684                format!("case:{case_number}"),
685                subject,
686                action_id,
687                "hypothesis:http-control",
688                stage,
689                "deterministic fixture is inconclusive",
690            )
691            .unwrap();
692        }
693        Outcome::verified(
694            format!("case:{case_number}"),
695            subject,
696            action_id,
697            "hypothesis:http-control",
698            "verify.http-control",
699            stage,
700            status,
701            Probability::from_percent(90).unwrap(),
702            "deterministic fixture outcome",
703            BTreeSet::from([EvidenceId::parse(format!("evidence:{case_number}")).unwrap()]),
704        )
705        .unwrap()
706    }
707
708    #[test]
709    fn repeated_confirmed_negatives_suppress_only_the_scoped_action() {
710        let target = subject("endpoint:https://example.test");
711        let other = subject("endpoint:https://other.test");
712        let mut store = ExperienceStore::new();
713        for case in 0..10 {
714            store
715                .observe(verified(
716                    case,
717                    target.clone(),
718                    "http.x-forwarded-host",
719                    VerificationStage::Active,
720                    OutcomeStatus::ConfirmedNegative,
721                ))
722                .unwrap();
723        }
724
725        let assessment = store.assess(
726            &target,
727            "http.x-forwarded-host",
728            ExperiencePolicy::default(),
729        );
730        assert_eq!(assessment.completed_attempts(), 10);
731        assert_eq!(assessment.consecutive_failures(), 10);
732        assert_eq!(
733            assessment.recommendation(),
734            ExperienceRecommendation::Suppress
735        );
736        assert_eq!(
737            store.suppressed_actions(&target, ExperiencePolicy::default()),
738            BTreeSet::from(["http.x-forwarded-host".to_owned()])
739        );
740        assert!(store
741            .suppressed_actions(&other, ExperiencePolicy::default())
742            .is_empty());
743    }
744
745    #[test]
746    fn operational_and_applicability_dispositions_are_neutral() {
747        let target = subject("endpoint:https://example.test");
748        let mut store = ExperienceStore::new();
749
750        let observations = [
751            (
752                OutcomeStatus::Blocked,
753                ExperienceDisposition::BlockedByTarget,
754            ),
755            (
756                OutcomeStatus::Unknown,
757                ExperienceDisposition::BlockedByPolicy,
758            ),
759            (
760                OutcomeStatus::Unknown,
761                ExperienceDisposition::TransportFailure,
762            ),
763            (
764                OutcomeStatus::Unknown,
765                ExperienceDisposition::ExecutorFailure,
766            ),
767            (
768                OutcomeStatus::Unknown,
769                ExperienceDisposition::VerificationInconclusive,
770            ),
771            (OutcomeStatus::Unknown, ExperienceDisposition::NotApplicable),
772        ];
773        for (case, (status, disposition)) in observations.into_iter().enumerate() {
774            store
775                .observe_with_disposition(
776                    verified(
777                        case,
778                        target.clone(),
779                        "http.enumeration",
780                        VerificationStage::Active,
781                        status,
782                    ),
783                    disposition,
784                )
785                .unwrap();
786        }
787
788        let assessment = store.assess(
789            &target,
790            "http.enumeration",
791            ExperiencePolicy::new(1).unwrap(),
792        );
793        assert_eq!(assessment.completed_attempts(), observations.len());
794        assert_eq!(assessment.consecutive_suppressible_failures(), 0);
795        assert_eq!(
796            assessment.last_disposition(),
797            Some(ExperienceDisposition::NotApplicable)
798        );
799        assert_eq!(
800            assessment.recommendation(),
801            ExperienceRecommendation::Continue
802        );
803    }
804
805    #[test]
806    fn neutral_observations_do_not_add_to_or_erase_negative_history() {
807        let target = subject("endpoint:https://example.test");
808        let mut store = ExperienceStore::new();
809        store
810            .observe(verified(
811                0,
812                target.clone(),
813                "http.enumeration",
814                VerificationStage::Passive,
815                OutcomeStatus::FalsePositive,
816            ))
817            .unwrap();
818        store
819            .observe(verified(
820                1,
821                target.clone(),
822                "http.enumeration",
823                VerificationStage::Active,
824                OutcomeStatus::Blocked,
825            ))
826            .unwrap();
827        store
828            .observe(verified(
829                2,
830                target.clone(),
831                "http.enumeration",
832                VerificationStage::Active,
833                OutcomeStatus::ConfirmedNegative,
834            ))
835            .unwrap();
836
837        let assessment = store.assess(
838            &target,
839            "http.enumeration",
840            ExperiencePolicy::new(2).unwrap(),
841        );
842        assert_eq!(assessment.consecutive_suppressible_failures(), 2);
843        assert!(assessment.is_suppressed());
844    }
845
846    #[test]
847    fn success_resets_the_suppressible_failure_streak() {
848        let target = subject("endpoint:https://example.test");
849        let mut store = ExperienceStore::new();
850        for case in 0..3 {
851            store
852                .observe(verified(
853                    case,
854                    target.clone(),
855                    "http.enumeration",
856                    VerificationStage::Active,
857                    OutcomeStatus::ConfirmedNegative,
858                ))
859                .unwrap();
860        }
861        store
862            .observe(verified(
863                3,
864                target.clone(),
865                "http.enumeration",
866                VerificationStage::Active,
867                OutcomeStatus::Success,
868            ))
869            .unwrap();
870
871        let assessment = store.assess(&target, "http.enumeration", ExperiencePolicy::default());
872        assert_eq!(assessment.consecutive_failures(), 0);
873        assert_eq!(assessment.last_status(), Some(OutcomeStatus::Success));
874        assert_eq!(
875            assessment.recommendation(),
876            ExperienceRecommendation::Continue
877        );
878    }
879
880    #[test]
881    fn active_result_supersedes_passive_inconclusive_result_for_one_case() {
882        let target = subject("endpoint:https://example.test");
883        let mut store = ExperienceStore::new();
884        store
885            .observe(
886                Outcome::unknown(
887                    "case:0",
888                    target.clone(),
889                    "sqli.boolean",
890                    "hypothesis:sqli",
891                    VerificationStage::Passive,
892                    "passive evidence is inconclusive",
893                )
894                .unwrap(),
895            )
896            .unwrap();
897        store
898            .observe(verified(
899                0,
900                target.clone(),
901                "sqli.boolean",
902                VerificationStage::Active,
903                OutcomeStatus::ConfirmedNegative,
904            ))
905            .unwrap();
906
907        let assessment = store.assess(&target, "sqli.boolean", ExperiencePolicy::new(1).unwrap());
908        assert_eq!(assessment.completed_attempts(), 1);
909        assert_eq!(assessment.last_stage(), Some(VerificationStage::Active));
910        assert_eq!(
911            assessment.last_disposition(),
912            Some(ExperienceDisposition::ConfirmedNegative)
913        );
914        assert!(assessment.is_suppressed());
915    }
916
917    #[test]
918    fn writes_are_idempotent_and_identity_conflicts_are_rejected() {
919        let target = subject("endpoint:https://example.test");
920        let original = verified(
921            0,
922            target.clone(),
923            "http.403-bypass",
924            VerificationStage::Active,
925            OutcomeStatus::Blocked,
926        );
927        let conflicting = verified(
928            0,
929            target,
930            "http.403-bypass",
931            VerificationStage::Active,
932            OutcomeStatus::Success,
933        );
934        let mut store = ExperienceStore::new();
935
936        assert_eq!(
937            store.observe(original.clone()).unwrap(),
938            ExperienceWrite::Inserted
939        );
940        assert_eq!(store.observe(original).unwrap(), ExperienceWrite::Unchanged);
941        let reclassified = verified(
942            1,
943            subject("endpoint:https://example.test"),
944            "http.403-bypass",
945            VerificationStage::Active,
946            OutcomeStatus::Unknown,
947        );
948        store
949            .observe_with_disposition(
950                reclassified.clone(),
951                ExperienceDisposition::TransportFailure,
952            )
953            .unwrap();
954        assert!(matches!(
955            store.observe_with_disposition(reclassified, ExperienceDisposition::ExecutorFailure,),
956            Err(ExperienceStoreError::IdentityConflict { .. })
957        ));
958        assert!(matches!(
959            store.observe(conflicting),
960            Err(ExperienceStoreError::IdentityConflict { .. })
961        ));
962        assert_eq!(store.len(), 2);
963    }
964
965    #[test]
966    fn store_round_trips_and_rejects_invalid_sequences() {
967        let target = subject("endpoint:https://example.test");
968        let mut store = ExperienceStore::new();
969        store
970            .observe_with_disposition(
971                verified(
972                    0,
973                    target,
974                    "http.enumeration",
975                    VerificationStage::Passive,
976                    OutcomeStatus::Unknown,
977                ),
978                ExperienceDisposition::BlockedByPolicy,
979            )
980            .unwrap();
981        let encoded = serde_json::to_value(&store).unwrap();
982        assert_eq!(
983            serde_json::from_value::<ExperienceStore>(encoded.clone()).unwrap(),
984            store
985        );
986        assert_eq!(
987            encoded["records"][0]["disposition"],
988            serde_json::json!("blocked_by_policy")
989        );
990
991        let mut legacy = encoded.clone();
992        legacy["records"][0]
993            .as_object_mut()
994            .unwrap()
995            .remove("disposition");
996        let migrated = serde_json::from_value::<ExperienceStore>(legacy).unwrap();
997        assert_eq!(
998            migrated.records()[0].disposition(),
999            ExperienceDisposition::VerificationInconclusive
1000        );
1001
1002        let mut incompatible = encoded.clone();
1003        incompatible["records"][0]["disposition"] = serde_json::json!("confirmed_positive");
1004        assert!(serde_json::from_value::<ExperienceStore>(incompatible).is_err());
1005
1006        let mut invalid_record = encoded.clone();
1007        invalid_record["records"][0]["sequence"] = serde_json::json!(1);
1008        assert!(serde_json::from_value::<ExperienceStore>(invalid_record).is_err());
1009
1010        let mut invalid_next = encoded;
1011        invalid_next["next_sequence"] = serde_json::json!(2);
1012        assert!(serde_json::from_value::<ExperienceStore>(invalid_next).is_err());
1013
1014        let mut duplicate = serde_json::to_value(&store).unwrap();
1015        let repeated = duplicate["records"][0].clone();
1016        duplicate["records"].as_array_mut().unwrap().push(repeated);
1017        duplicate["records"][1]["sequence"] = serde_json::json!(1);
1018        duplicate["next_sequence"] = serde_json::json!(2);
1019        assert!(serde_json::from_value::<ExperienceStore>(duplicate).is_err());
1020        assert!(
1021            serde_json::from_value::<ExperiencePolicy>(serde_json::json!({
1022                "consecutive_failure_limit": 0
1023            }))
1024            .is_err()
1025        );
1026    }
1027}