1use std::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Deserializer, Serialize};
22use termivar_core::{EntityId, Outcome, OutcomeStatus, VerificationStage};
23use thiserror::Error;
24
25#[derive(Debug, Clone, PartialEq, Eq, Error)]
27#[non_exhaustive]
28pub enum ExperienceStoreError {
29 #[error("consecutive suppression-eligible failure limit must be greater than zero")]
31 ZeroFailureLimit,
32
33 #[error(
36 "experience identity conflict for subject {subject}, action {action_id}, case {case_id}, stage {stage:?}"
37 )]
38 IdentityConflict {
39 subject: EntityId,
41 action_id: String,
43 case_id: String,
45 stage: VerificationStage,
47 },
48
49 #[error("experience observation sequence overflowed")]
51 SequenceOverflow,
52
53 #[error("invalid persisted experience sequence: expected {expected}, found {actual}")]
55 InvalidSequence {
56 expected: u64,
58 actual: u64,
60 },
61
62 #[error("invalid next experience sequence: expected {expected}, found {actual}")]
64 InvalidNextSequence {
65 expected: u64,
67 actual: u64,
69 },
70
71 #[error("persisted experience contains a duplicate observation")]
73 DuplicateObservation,
74
75 #[error("experience disposition {disposition:?} is incompatible with outcome {status:?}")]
77 IncompatibleDisposition {
78 status: OutcomeStatus,
80 disposition: ExperienceDisposition,
82 },
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88#[non_exhaustive]
89pub enum ExperienceWrite {
90 Inserted,
92 Unchanged,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105#[non_exhaustive]
106pub enum ExperienceDisposition {
107 ConfirmedPositive,
114 NotApplicable,
116 BlockedByTarget,
118 BlockedByPolicy,
120 TransportFailure,
122 ExecutorFailure,
124 VerificationRejected,
126 VerificationInconclusive,
128 ConfirmedNegative,
130}
131
132impl ExperienceDisposition {
133 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189pub struct ExperiencePolicy {
190 consecutive_failure_limit: u16,
191}
192
193impl ExperiencePolicy {
194 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 pub fn consecutive_failure_limit(self) -> u16 {
211 self.consecutive_failure_limit
212 }
213
214 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246#[non_exhaustive]
247pub enum ExperienceRecommendation {
248 Explore,
250 Continue,
252 Suppress,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
258pub struct ExperienceRecord {
259 sequence: u64,
260 outcome: Outcome,
261 disposition: ExperienceDisposition,
262}
263
264impl ExperienceRecord {
265 pub fn sequence(&self) -> u64 {
267 self.sequence
268 }
269
270 pub fn outcome(&self) -> &Outcome {
272 &self.outcome
273 }
274
275 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#[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 pub fn subject(&self) -> &EntityId {
324 &self.subject
325 }
326
327 pub fn action_id(&self) -> &str {
329 &self.action_id
330 }
331
332 pub fn completed_attempts(&self) -> usize {
334 self.completed_attempts
335 }
336
337 pub fn consecutive_failures(&self) -> u16 {
342 self.consecutive_failures
343 }
344
345 pub fn consecutive_suppressible_failures(&self) -> u16 {
347 self.consecutive_failures
348 }
349
350 pub fn last_status(&self) -> Option<OutcomeStatus> {
352 self.last_status
353 }
354
355 pub fn last_stage(&self) -> Option<VerificationStage> {
357 self.last_stage
358 }
359
360 pub fn last_disposition(&self) -> Option<ExperienceDisposition> {
362 self.last_disposition
363 }
364
365 pub fn recommendation(&self) -> ExperienceRecommendation {
367 self.recommendation
368 }
369
370 pub fn rationale(&self) -> &str {
372 &self.rationale
373 }
374
375 pub fn is_suppressed(&self) -> bool {
377 self.recommendation == ExperienceRecommendation::Suppress
378 }
379}
380
381#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
405pub struct ExperienceStore {
406 next_sequence: u64,
407 records: Vec<ExperienceRecord>,
408}
409
410impl ExperienceStore {
411 pub fn new() -> Self {
413 Self::default()
414 }
415
416 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 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 pub fn len(&self) -> usize {
466 self.records.len()
467 }
468
469 pub fn is_empty(&self) -> bool {
471 self.records.is_empty()
472 }
473
474 pub fn records(&self) -> &[ExperienceRecord] {
476 &self.records
477 }
478
479 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 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 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}