1use std::collections::{BTreeMap, BTreeSet};
18
19use serde::{de::IgnoredAny, Deserialize, Deserializer, Serialize};
20use termivar_core::{
21 EntityId, EvidenceId, HypothesisState, Outcome, OutcomeError, OutcomeStatus, Probability,
22 VerificationStage,
23};
24use thiserror::Error;
25
26use crate::{
27 knowledge::{
28 HypothesisStateTransition, KnowledgeAuthority, KnowledgeBase, KnowledgeBaseError,
29 KnowledgeSnapshot, KnowledgeWrite,
30 },
31 payload_strategy::PayloadStrategyRef,
32 rules::{Expression, ExpressionEvaluation, RuleEngineError},
33};
34
35#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum VerificationError {
39 #[error("{field} must not be empty")]
41 EmptyValue { field: &'static str },
42
43 #[error("verification rule {rule_id} cannot emit the reserved unknown status")]
45 ReservedUnknownStatus { rule_id: String },
46
47 #[error("verification rule {rule_id} must have non-zero confidence")]
49 ZeroConfidence { rule_id: String },
50
51 #[error("verification rule {rule_id} must use the active stage for confirmed negatives")]
53 ConfirmedNegativeRequiresActive { rule_id: String },
54
55 #[error(
57 "verification rule {rule_id} must use only raw evidence when case correlation is required"
58 )]
59 CaseCorrelationRequiresEvidenceOnly { rule_id: String },
60
61 #[error("verification rule {rule_id} belongs to {actual:?}, expected {expected:?}")]
63 WrongStage {
64 rule_id: String,
66 expected: VerificationStage,
68 actual: VerificationStage,
70 },
71
72 #[error("verification rule identity {id} already has a different definition")]
74 RuleIdentityConflict { id: String },
75
76 #[error("verification case subject {expected} does not match snapshot subject {actual}")]
78 SnapshotSubjectMismatch {
79 expected: EntityId,
81 actual: EntityId,
83 },
84
85 #[error("verification hypothesis {hypothesis_id} was not found")]
87 UnknownHypothesis { hypothesis_id: String },
88
89 #[error("matched verification rule {rule_id} did not cite any evidence")]
91 MissingContributingEvidence { rule_id: String },
92
93 #[error("active snapshot is missing baseline evidence {evidence_id}")]
95 NonMonotonicSnapshot { evidence_id: EvidenceId },
96
97 #[error("verification evidence {evidence_id} was not found")]
99 UnknownEvidence { evidence_id: EvidenceId },
100
101 #[error("verification evidence {evidence_id} does not belong to subject {subject}")]
103 EvidenceSubjectMismatch {
104 evidence_id: EvidenceId,
106 subject: EntityId,
108 },
109
110 #[error(
112 "verification cannot change terminal hypothesis {hypothesis_id} from {current:?} to {attempted:?}"
113 )]
114 ConflictingTerminalState {
115 hypothesis_id: String,
117 current: HypothesisState,
119 attempted: HypothesisState,
121 },
122
123 #[error(transparent)]
125 Rule(#[from] RuleEngineError),
126
127 #[error(transparent)]
129 Outcome(#[from] OutcomeError),
130
131 #[error(transparent)]
133 Knowledge(#[from] KnowledgeBaseError),
134}
135
136fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, VerificationError> {
137 let value = value.into();
138 if value.trim().is_empty() {
139 return Err(VerificationError::EmptyValue { field });
140 }
141 Ok(value)
142}
143
144fn is_false(value: &bool) -> bool {
145 !*value
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150enum VerificationScopeGuard {
151 Action,
152 Case,
153 ActionAndCase,
154}
155
156impl VerificationScopeGuard {
157 fn for_scope(action_id: Option<&str>, case_correlated_evidence: bool) -> Option<Self> {
158 match (action_id.is_some(), case_correlated_evidence) {
159 (true, false) => Some(Self::Action),
160 (false, true) => Some(Self::Case),
161 (true, true) => Some(Self::ActionAndCase),
162 (false, false) => None,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173pub struct VerificationCase {
174 id: String,
175 subject: EntityId,
176 action_id: String,
177 hypothesis_id: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 payload_strategy: Option<PayloadStrategyRef>,
180 #[serde(skip_serializing_if = "transition_is_default")]
181 applies_hypothesis_transition: bool,
182 #[serde(
186 default,
187 rename = "payload_claim_policy_guard",
188 skip_serializing_if = "is_false"
189 )]
190 claim_policy_guard: bool,
191}
192
193impl VerificationCase {
194 pub fn new(
200 id: impl Into<String>,
201 subject: EntityId,
202 action_id: impl Into<String>,
203 hypothesis_id: impl Into<String>,
204 ) -> Result<Self, VerificationError> {
205 Ok(Self {
206 id: non_empty(id, "verification case id")?,
207 subject,
208 action_id: non_empty(action_id, "verification action id")?,
209 hypothesis_id: non_empty(hypothesis_id, "verification hypothesis id")?,
210 payload_strategy: None,
211 applies_hypothesis_transition: true,
212 claim_policy_guard: false,
213 })
214 }
215
216 pub fn with_payload_strategy(mut self, strategy: Option<PayloadStrategyRef>) -> Self {
218 self.payload_strategy = strategy;
219 self
220 }
221
222 pub fn without_hypothesis_transition(mut self) -> Self {
227 self.applies_hypothesis_transition = false;
228 self.claim_policy_guard = true;
229 self
230 }
231
232 pub fn applies_hypothesis_transition(&self) -> bool {
234 self.applies_hypothesis_transition
235 }
236
237 pub fn id(&self) -> &str {
239 &self.id
240 }
241
242 pub fn subject(&self) -> &EntityId {
244 &self.subject
245 }
246
247 pub fn action_id(&self) -> &str {
249 &self.action_id
250 }
251
252 pub fn hypothesis_id(&self) -> &str {
255 &self.hypothesis_id
256 }
257
258 pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
260 self.payload_strategy.as_ref()
261 }
262}
263
264impl<'de> Deserialize<'de> for VerificationCase {
265 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266 where
267 D: Deserializer<'de>,
268 {
269 #[derive(Deserialize)]
270 struct WireCase {
271 id: String,
272 subject: EntityId,
273 action_id: String,
274 hypothesis_id: String,
275 #[serde(default)]
276 payload_strategy: Option<PayloadStrategyRef>,
277 #[serde(default = "default_applies_transition")]
278 applies_hypothesis_transition: bool,
279 #[serde(default)]
280 payload_claim_policy_guard: bool,
281 #[serde(flatten)]
282 extensions: BTreeMap<String, IgnoredAny>,
283 }
284
285 let wire = WireCase::deserialize(deserializer)?;
286 if wire.extensions.keys().any(|field| {
287 field.starts_with("payload_")
288 || field.starts_with("applies_hypothesis_")
289 || field.starts_with("verification_")
290 }) {
291 return Err(serde::de::Error::custom(
292 "unknown reserved verification case field",
293 ));
294 }
295 if wire.payload_claim_policy_guard == wire.applies_hypothesis_transition {
296 return Err(serde::de::Error::custom(
297 "verification case compatibility guard is missing or inconsistent",
298 ));
299 }
300 let case = Self::new(wire.id, wire.subject, wire.action_id, wire.hypothesis_id)
301 .map(|case| case.with_payload_strategy(wire.payload_strategy))
302 .map_err(serde::de::Error::custom)?;
303 Ok(if wire.applies_hypothesis_transition {
304 case
305 } else {
306 case.without_hypothesis_transition()
307 })
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct VerificationRule {
314 id: String,
315 stage: VerificationStage,
316 priority: u16,
317 condition: Expression,
318 outcome: OutcomeStatus,
319 confidence: Probability,
320 rationale: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 action_id: Option<String>,
323 #[serde(default, skip_serializing_if = "is_false")]
324 case_correlated_evidence: bool,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 verification_scope_guard: Option<VerificationScopeGuard>,
327}
328
329impl VerificationRule {
330 #[allow(clippy::too_many_arguments)]
332 pub fn new(
333 id: impl Into<String>,
334 stage: VerificationStage,
335 priority: u16,
336 condition: Expression,
337 outcome: OutcomeStatus,
338 confidence: Probability,
339 rationale: impl Into<String>,
340 ) -> Result<Self, VerificationError> {
341 let id = non_empty(id, "verification rule id")?;
342 if outcome == OutcomeStatus::Unknown {
343 return Err(VerificationError::ReservedUnknownStatus { rule_id: id });
344 }
345 if outcome == OutcomeStatus::ConfirmedNegative && stage != VerificationStage::Active {
346 return Err(VerificationError::ConfirmedNegativeRequiresActive { rule_id: id });
347 }
348 if confidence == Probability::ZERO {
349 return Err(VerificationError::ZeroConfidence { rule_id: id });
350 }
351 Ok(Self {
352 id,
353 stage,
354 priority,
355 condition,
356 outcome,
357 confidence,
358 rationale: non_empty(rationale, "verification rule rationale")?,
359 action_id: None,
360 case_correlated_evidence: false,
361 verification_scope_guard: None,
362 })
363 }
364
365 pub fn scoped_to_action(
367 mut self,
368 action_id: impl Into<String>,
369 ) -> Result<Self, VerificationError> {
370 self.action_id = Some(non_empty(action_id, "verification rule action id")?);
371 self.refresh_scope_guard();
372 Ok(self)
373 }
374
375 pub fn with_case_correlated_evidence(mut self) -> Result<Self, VerificationError> {
380 if !self.condition.uses_only_evidence() {
381 return Err(VerificationError::CaseCorrelationRequiresEvidenceOnly {
382 rule_id: self.id.clone(),
383 });
384 }
385 self.case_correlated_evidence = true;
386 self.refresh_scope_guard();
387 Ok(self)
388 }
389
390 fn refresh_scope_guard(&mut self) {
391 self.verification_scope_guard = VerificationScopeGuard::for_scope(
392 self.action_id.as_deref(),
393 self.case_correlated_evidence,
394 );
395 }
396
397 pub fn id(&self) -> &str {
399 &self.id
400 }
401
402 pub fn stage(&self) -> VerificationStage {
404 self.stage
405 }
406
407 pub fn priority(&self) -> u16 {
409 self.priority
410 }
411
412 pub fn condition(&self) -> &Expression {
414 &self.condition
415 }
416
417 pub fn outcome(&self) -> OutcomeStatus {
419 self.outcome
420 }
421
422 pub fn confidence(&self) -> Probability {
424 self.confidence
425 }
426
427 pub fn rationale(&self) -> &str {
429 &self.rationale
430 }
431
432 pub fn action_id(&self) -> Option<&str> {
434 self.action_id.as_deref()
435 }
436
437 pub fn requires_case_correlated_evidence(&self) -> bool {
439 self.case_correlated_evidence
440 }
441}
442
443impl<'de> Deserialize<'de> for VerificationRule {
444 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
445 where
446 D: Deserializer<'de>,
447 {
448 #[derive(Deserialize)]
449 #[serde(deny_unknown_fields)]
450 struct WireRule {
451 id: String,
452 stage: VerificationStage,
453 priority: u16,
454 condition: Expression,
455 outcome: OutcomeStatus,
456 confidence: Probability,
457 rationale: String,
458 #[serde(default)]
459 action_id: Option<String>,
460 #[serde(default)]
461 case_correlated_evidence: bool,
462 #[serde(default)]
463 verification_scope_guard: Option<VerificationScopeGuard>,
464 }
465
466 let wire = WireRule::deserialize(deserializer)?;
467 let expected_scope_guard = VerificationScopeGuard::for_scope(
468 wire.action_id.as_deref(),
469 wire.case_correlated_evidence,
470 );
471 if wire.verification_scope_guard.is_some()
472 && wire.verification_scope_guard != expected_scope_guard
473 {
474 return Err(serde::de::Error::custom(
475 "verification rule scope guard is inconsistent",
476 ));
477 }
478 let mut rule = Self::new(
479 wire.id,
480 wire.stage,
481 wire.priority,
482 wire.condition,
483 wire.outcome,
484 wire.confidence,
485 wire.rationale,
486 )
487 .map_err(serde::de::Error::custom)?;
488 if let Some(action_id) = wire.action_id {
489 rule = rule
490 .scoped_to_action(action_id)
491 .map_err(serde::de::Error::custom)?;
492 }
493 if wire.case_correlated_evidence {
494 rule = rule
495 .with_case_correlated_evidence()
496 .map_err(serde::de::Error::custom)?;
497 }
498 Ok(rule)
499 }
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505#[non_exhaustive]
506pub enum VerifierWrite {
507 Inserted,
509 Unchanged,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
515pub struct VerificationRuleEvaluation {
516 rule_id: String,
517 stage: VerificationStage,
518 priority: u16,
519 condition: ExpressionEvaluation,
520 fresh_evidence_ids: BTreeSet<EvidenceId>,
521 action_matched: bool,
522 eligible: bool,
523 selected: bool,
524}
525
526impl VerificationRuleEvaluation {
527 pub fn rule_id(&self) -> &str {
529 &self.rule_id
530 }
531
532 pub fn stage(&self) -> VerificationStage {
534 self.stage
535 }
536
537 pub fn priority(&self) -> u16 {
539 self.priority
540 }
541
542 pub fn condition(&self) -> &ExpressionEvaluation {
544 &self.condition
545 }
546
547 pub fn fresh_evidence_ids(&self) -> &BTreeSet<EvidenceId> {
549 &self.fresh_evidence_ids
550 }
551
552 pub fn action_matched(&self) -> bool {
554 self.action_matched
555 }
556
557 pub fn eligible(&self) -> bool {
559 self.eligible
560 }
561
562 pub fn selected(&self) -> bool {
564 self.selected
565 }
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
570pub struct VerificationReport {
571 case: VerificationCase,
572 stage: VerificationStage,
573 outcome: Outcome,
574 evaluations: Vec<VerificationRuleEvaluation>,
575 #[serde(skip)]
576 commit_token: VerificationCommitToken,
577}
578
579#[derive(Debug, Clone)]
580struct VerificationCommitToken {
581 authority: KnowledgeAuthority,
582 subject_revision: u64,
583 ontology_revision: u64,
584}
585
586impl PartialEq for VerificationCommitToken {
590 fn eq(&self, other: &Self) -> bool {
591 self.subject_revision == other.subject_revision
592 && self.ontology_revision == other.ontology_revision
593 }
594}
595
596impl Eq for VerificationCommitToken {}
597
598impl VerificationReport {
599 pub fn case(&self) -> &VerificationCase {
601 &self.case
602 }
603
604 pub fn stage(&self) -> VerificationStage {
606 self.stage
607 }
608
609 pub fn outcome(&self) -> &Outcome {
611 &self.outcome
612 }
613
614 pub fn evaluations(&self) -> &[VerificationRuleEvaluation] {
616 &self.evaluations
617 }
618
619 pub(crate) fn prospective_snapshot(
627 &self,
628 snapshot: &KnowledgeSnapshot,
629 ) -> Result<KnowledgeSnapshot, VerificationError> {
630 if !self.case.applies_hypothesis_transition() {
631 return Ok(snapshot.clone());
632 }
633 let Some(state) = self.outcome.status().hypothesis_state() else {
634 return Ok(snapshot.clone());
635 };
636 snapshot
637 .with_projected_hypothesis_state(self.outcome.hypothesis_id(), state)
638 .ok_or_else(|| VerificationError::UnknownHypothesis {
639 hypothesis_id: self.outcome.hypothesis_id().to_owned(),
640 })
641 }
642
643 pub fn apply(
654 &self,
655 knowledge: &KnowledgeBase,
656 ) -> Result<Option<KnowledgeWrite>, VerificationError> {
657 if !self.case.applies_hypothesis_transition() {
658 validate_commit_token(knowledge, self.case.subject(), &self.commit_token)?;
663 return Ok(None);
664 }
665 apply_outcome_with_token(knowledge, &self.outcome, Some(&self.commit_token))
666 }
667}
668
669fn default_applies_transition() -> bool {
670 true
671}
672
673fn transition_is_default(applies: &bool) -> bool {
674 *applies
675}
676
677#[derive(Debug, Clone)]
678struct RuleRegistry {
679 stage: VerificationStage,
680 rules: BTreeMap<String, VerificationRule>,
681}
682
683impl RuleRegistry {
684 fn new(stage: VerificationStage) -> Self {
685 Self {
686 stage,
687 rules: BTreeMap::new(),
688 }
689 }
690
691 fn register(&mut self, rule: VerificationRule) -> Result<VerifierWrite, VerificationError> {
692 if rule.stage != self.stage {
693 return Err(VerificationError::WrongStage {
694 rule_id: rule.id.clone(),
695 expected: self.stage,
696 actual: rule.stage,
697 });
698 }
699 if let Some(existing) = self.rules.get(rule.id()) {
700 return if existing == &rule {
701 Ok(VerifierWrite::Unchanged)
702 } else {
703 Err(VerificationError::RuleIdentityConflict {
704 id: rule.id.clone(),
705 })
706 };
707 }
708 self.rules.insert(rule.id.clone(), rule);
709 Ok(VerifierWrite::Inserted)
710 }
711
712 fn len(&self) -> usize {
713 self.rules.len()
714 }
715
716 fn is_empty(&self) -> bool {
717 self.rules.is_empty()
718 }
719}
720
721#[derive(Debug, Clone)]
752pub struct PassiveVerifier {
753 registry: RuleRegistry,
754}
755
756impl PassiveVerifier {
757 pub fn new() -> Self {
759 Self {
760 registry: RuleRegistry::new(VerificationStage::Passive),
761 }
762 }
763
764 pub fn register(&mut self, rule: VerificationRule) -> Result<VerifierWrite, VerificationError> {
766 self.registry.register(rule)
767 }
768
769 pub fn len(&self) -> usize {
771 self.registry.len()
772 }
773
774 pub fn is_empty(&self) -> bool {
776 self.registry.is_empty()
777 }
778
779 pub fn verify(
781 &self,
782 knowledge: &KnowledgeBase,
783 case: &VerificationCase,
784 ) -> Result<VerificationReport, VerificationError> {
785 let snapshot = knowledge.snapshot_for_subject(case.subject());
786 self.verify_snapshot(case, &snapshot)
787 }
788
789 pub fn verify_snapshot(
791 &self,
792 case: &VerificationCase,
793 snapshot: &KnowledgeSnapshot,
794 ) -> Result<VerificationReport, VerificationError> {
795 evaluate_registry(&self.registry, case, snapshot, None)
796 }
797}
798
799impl Default for PassiveVerifier {
800 fn default() -> Self {
801 Self::new()
802 }
803}
804
805#[derive(Debug, Clone)]
812pub struct ActiveVerifier {
813 registry: RuleRegistry,
814}
815
816impl ActiveVerifier {
817 pub fn new() -> Self {
819 Self {
820 registry: RuleRegistry::new(VerificationStage::Active),
821 }
822 }
823
824 pub fn register(&mut self, rule: VerificationRule) -> Result<VerifierWrite, VerificationError> {
826 self.registry.register(rule)
827 }
828
829 pub fn len(&self) -> usize {
831 self.registry.len()
832 }
833
834 pub fn is_empty(&self) -> bool {
836 self.registry.is_empty()
837 }
838
839 pub fn verify_snapshots(
841 &self,
842 case: &VerificationCase,
843 baseline: &KnowledgeSnapshot,
844 after_probe: &KnowledgeSnapshot,
845 ) -> Result<VerificationReport, VerificationError> {
846 validate_snapshot(case, baseline)?;
847 validate_monotonic(baseline, after_probe)?;
848 evaluate_registry(&self.registry, case, after_probe, Some(baseline))
849 }
850}
851
852impl Default for ActiveVerifier {
853 fn default() -> Self {
854 Self::new()
855 }
856}
857
858#[derive(Debug, Clone, Default)]
860pub struct VerificationPipeline {
861 passive: PassiveVerifier,
862 active: ActiveVerifier,
863}
864
865impl VerificationPipeline {
866 pub fn new(passive: PassiveVerifier, active: ActiveVerifier) -> Self {
868 Self { passive, active }
869 }
870
871 pub fn passive(&self) -> &PassiveVerifier {
873 &self.passive
874 }
875
876 pub fn passive_mut(&mut self) -> &mut PassiveVerifier {
878 &mut self.passive
879 }
880
881 pub fn active(&self) -> &ActiveVerifier {
883 &self.active
884 }
885
886 pub fn active_mut(&mut self) -> &mut ActiveVerifier {
888 &mut self.active
889 }
890
891 pub fn verify_snapshots(
897 &self,
898 case: &VerificationCase,
899 passive_snapshot: &KnowledgeSnapshot,
900 active_snapshot: Option<&KnowledgeSnapshot>,
901 ) -> Result<VerificationPipelineReport, VerificationError> {
902 let passive = self.passive.verify_snapshot(case, passive_snapshot)?;
903 let active = if passive.outcome().status().is_terminal() {
904 None
905 } else {
906 active_snapshot
907 .map(|snapshot| {
908 self.active
909 .verify_snapshots(case, passive_snapshot, snapshot)
910 })
911 .transpose()?
912 };
913 Ok(VerificationPipelineReport { passive, active })
914 }
915}
916
917#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
919pub struct VerificationPipelineReport {
920 passive: VerificationReport,
921 active: Option<VerificationReport>,
922}
923
924impl VerificationPipelineReport {
925 pub fn passive(&self) -> &VerificationReport {
927 &self.passive
928 }
929
930 pub fn active(&self) -> Option<&VerificationReport> {
932 self.active.as_ref()
933 }
934
935 pub fn final_outcome(&self) -> &Outcome {
937 self.active
938 .as_ref()
939 .map_or_else(|| self.passive.outcome(), VerificationReport::outcome)
940 }
941
942 pub fn requires_active(&self) -> bool {
944 !self.passive.outcome().status().is_terminal() && self.active.is_none()
945 }
946}
947
948pub fn apply_outcome(
962 knowledge: &KnowledgeBase,
963 outcome: &Outcome,
964) -> Result<Option<KnowledgeWrite>, VerificationError> {
965 apply_outcome_with_token(knowledge, outcome, None)
966}
967
968fn apply_outcome_with_token(
969 knowledge: &KnowledgeBase,
970 outcome: &Outcome,
971 commit_token: Option<&VerificationCommitToken>,
972) -> Result<Option<KnowledgeWrite>, VerificationError> {
973 let Some(state) = outcome.status().hypothesis_state() else {
974 if let Some(commit_token) = commit_token {
975 validate_commit_token(knowledge, outcome.subject(), commit_token)?;
976 }
977 return Ok(None);
978 };
979 if let Some(commit_token) = commit_token {
980 knowledge.validate_snapshot_authority(&commit_token.authority, outcome.subject())?;
981 }
982 let hypothesis = knowledge
983 .hypothesis(outcome.hypothesis_id())
984 .ok_or_else(|| VerificationError::UnknownHypothesis {
985 hypothesis_id: outcome.hypothesis_id().to_owned(),
986 })?;
987 if hypothesis.subject() != outcome.subject() {
988 return Err(VerificationError::SnapshotSubjectMismatch {
989 expected: outcome.subject().clone(),
990 actual: hypothesis.subject().clone(),
991 });
992 }
993 for evidence_id in outcome.evidence_ids() {
994 let evidence =
995 knowledge
996 .evidence(evidence_id)
997 .ok_or_else(|| VerificationError::UnknownEvidence {
998 evidence_id: evidence_id.clone(),
999 })?;
1000 if evidence.subject() != outcome.subject() {
1001 return Err(VerificationError::EvidenceSubjectMismatch {
1002 evidence_id: evidence_id.clone(),
1003 subject: outcome.subject().clone(),
1004 });
1005 }
1006 }
1007 let expected_revisions =
1008 commit_token.map(|token| (token.subject_revision, token.ontology_revision));
1009 match knowledge.transition_hypothesis_state(
1010 outcome.hypothesis_id(),
1011 outcome.subject(),
1012 state,
1013 expected_revisions,
1014 ) {
1015 HypothesisStateTransition::Missing => Err(VerificationError::UnknownHypothesis {
1016 hypothesis_id: outcome.hypothesis_id().to_owned(),
1017 }),
1018 HypothesisStateTransition::SubjectMismatch { actual } => {
1019 Err(VerificationError::SnapshotSubjectMismatch {
1020 expected: outcome.subject().clone(),
1021 actual,
1022 })
1023 },
1024 HypothesisStateTransition::StaleSnapshot(error) => Err(error.into()),
1025 HypothesisStateTransition::TerminalConflict { current, attempted } => {
1026 Err(VerificationError::ConflictingTerminalState {
1027 hypothesis_id: outcome.hypothesis_id().to_owned(),
1028 current,
1029 attempted,
1030 })
1031 },
1032 HypothesisStateTransition::Written(write) => Ok(Some(write)),
1033 }
1034}
1035
1036fn validate_commit_token(
1037 knowledge: &KnowledgeBase,
1038 subject: &EntityId,
1039 commit_token: &VerificationCommitToken,
1040) -> Result<(), VerificationError> {
1041 knowledge.validate_snapshot_authority(&commit_token.authority, subject)?;
1042 knowledge
1043 .validate_snapshot_revisions(
1044 subject,
1045 commit_token.subject_revision,
1046 commit_token.ontology_revision,
1047 )
1048 .map_err(Into::into)
1049}
1050
1051fn evaluate_registry(
1052 registry: &RuleRegistry,
1053 case: &VerificationCase,
1054 snapshot: &KnowledgeSnapshot,
1055 baseline: Option<&KnowledgeSnapshot>,
1056) -> Result<VerificationReport, VerificationError> {
1057 validate_snapshot(case, snapshot)?;
1058 let baseline_ids: BTreeSet<_> = baseline
1059 .map(|snapshot| {
1060 snapshot
1061 .evidence()
1062 .iter()
1063 .map(|evidence| evidence.id().clone())
1064 .collect()
1065 })
1066 .unwrap_or_default();
1067
1068 let mut evaluations = Vec::with_capacity(registry.rules.len());
1069 for rule in registry.rules.values() {
1070 let scoped_snapshot = rule
1071 .case_correlated_evidence
1072 .then(|| snapshot.with_evidence_correlation(case.id()));
1073 let condition = rule
1074 .condition
1075 .evaluate(scoped_snapshot.as_ref().unwrap_or(snapshot))?;
1076 if condition.matched() && condition.evidence_ids().is_empty() {
1077 return Err(VerificationError::MissingContributingEvidence {
1078 rule_id: rule.id.clone(),
1079 });
1080 }
1081 let fresh_evidence_ids = condition
1082 .evidence_ids()
1083 .difference(&baseline_ids)
1084 .cloned()
1085 .collect::<BTreeSet<_>>();
1086 let action_matched = rule
1087 .action_id
1088 .as_deref()
1089 .is_none_or(|action_id| action_id == case.action_id());
1090 let eligible = action_matched
1091 && condition.matched()
1092 && (registry.stage == VerificationStage::Passive || !fresh_evidence_ids.is_empty());
1093 evaluations.push(VerificationRuleEvaluation {
1094 rule_id: rule.id.clone(),
1095 stage: rule.stage,
1096 priority: rule.priority,
1097 condition,
1098 fresh_evidence_ids,
1099 action_matched,
1100 eligible,
1101 selected: false,
1102 });
1103 }
1104
1105 let mut candidates: Vec<_> = evaluations
1106 .iter()
1107 .filter(|evaluation| evaluation.eligible)
1108 .map(|evaluation| evaluation.rule_id.clone())
1109 .collect();
1110 candidates.sort_by(|left, right| {
1111 let left_rule = ®istry.rules[left];
1112 let right_rule = ®istry.rules[right];
1113 right_rule
1114 .priority
1115 .cmp(&left_rule.priority)
1116 .then_with(|| right_rule.confidence.cmp(&left_rule.confidence))
1117 .then_with(|| left.cmp(right))
1118 });
1119 let selected_id = candidates.first().cloned();
1120 if let Some(selected_id) = &selected_id {
1121 if let Some(evaluation) = evaluations
1122 .iter_mut()
1123 .find(|evaluation| &evaluation.rule_id == selected_id)
1124 {
1125 evaluation.selected = true;
1126 }
1127 }
1128
1129 let outcome = if let Some(selected_id) = selected_id {
1130 let rule = ®istry.rules[&selected_id];
1131 let evidence_ids = evaluations
1132 .iter()
1133 .find(|evaluation| evaluation.rule_id == selected_id)
1134 .map(|evaluation| evaluation.condition.evidence_ids().clone())
1135 .unwrap_or_default();
1136 Outcome::verified(
1137 case.id.clone(),
1138 case.subject.clone(),
1139 case.action_id.clone(),
1140 case.hypothesis_id.clone(),
1141 rule.id.clone(),
1142 registry.stage,
1143 rule.outcome,
1144 rule.confidence,
1145 rule.rationale.clone(),
1146 evidence_ids,
1147 )?
1148 } else {
1149 Outcome::unknown(
1150 case.id.clone(),
1151 case.subject.clone(),
1152 case.action_id.clone(),
1153 case.hypothesis_id.clone(),
1154 registry.stage,
1155 format!(
1156 "no eligible {} verification rule matched current evidence",
1157 registry.stage.as_str()
1158 ),
1159 )?
1160 };
1161
1162 Ok(VerificationReport {
1163 case: case.clone(),
1164 stage: registry.stage,
1165 outcome,
1166 evaluations,
1167 commit_token: VerificationCommitToken {
1168 authority: snapshot.authority().clone(),
1169 subject_revision: snapshot.subject_revision(),
1170 ontology_revision: snapshot.ontology_revision(),
1171 },
1172 })
1173}
1174
1175fn validate_snapshot(
1176 case: &VerificationCase,
1177 snapshot: &KnowledgeSnapshot,
1178) -> Result<(), VerificationError> {
1179 if snapshot.subject() != case.subject() {
1180 return Err(VerificationError::SnapshotSubjectMismatch {
1181 expected: case.subject().clone(),
1182 actual: snapshot.subject().clone(),
1183 });
1184 }
1185 if !snapshot
1186 .hypotheses()
1187 .iter()
1188 .any(|hypothesis| hypothesis.id() == case.hypothesis_id())
1189 {
1190 return Err(VerificationError::UnknownHypothesis {
1191 hypothesis_id: case.hypothesis_id().to_owned(),
1192 });
1193 }
1194 Ok(())
1195}
1196
1197fn validate_monotonic(
1198 baseline: &KnowledgeSnapshot,
1199 after_probe: &KnowledgeSnapshot,
1200) -> Result<(), VerificationError> {
1201 if !baseline.authority().is_same_as(after_probe.authority()) {
1202 return Err(KnowledgeBaseError::SnapshotAuthorityMismatch {
1203 subject: after_probe.subject().clone(),
1204 }
1205 .into());
1206 }
1207 let after_ids: BTreeSet<_> = after_probe
1208 .evidence()
1209 .iter()
1210 .map(|evidence| evidence.id())
1211 .collect();
1212 for evidence in baseline.evidence() {
1213 if !after_ids.contains(evidence.id()) {
1214 return Err(VerificationError::NonMonotonicSnapshot {
1215 evidence_id: evidence.id().clone(),
1216 });
1217 }
1218 }
1219 Ok(())
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224 use super::*;
1225 use crate::KnowledgeLayer;
1226 use termivar_core::{
1227 BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, EvidenceValue,
1228 Hypothesis, HypothesisState, HypothesisStrength, KnowledgePredicate,
1229 };
1230
1231 fn subject() -> EntityId {
1232 EntityId::new("endpoint:https://example.test").unwrap()
1233 }
1234
1235 fn boolean_predicate() -> KnowledgePredicate {
1236 KnowledgePredicate::new("verification", "boolean_difference").unwrap()
1237 }
1238
1239 fn timing_predicate() -> KnowledgePredicate {
1240 KnowledgePredicate::new("verification", "timing_difference").unwrap()
1241 }
1242
1243 fn evidence(predicate: KnowledgePredicate, method: &str, value: bool) -> Evidence {
1244 Evidence::new(
1245 subject(),
1246 EvidenceKind::Custom("verification".into()),
1247 predicate,
1248 EvidenceValue::Boolean(value),
1249 EvidenceSource::new("verifier", method).unwrap(),
1250 ConfidenceScore::from_percent(95).unwrap(),
1251 )
1252 }
1253
1254 fn knowledge() -> KnowledgeBase {
1255 let knowledge = KnowledgeBase::new();
1256 let observation = evidence(boolean_predicate(), "boolean-control", true);
1257 knowledge.insert_evidence(observation.clone()).unwrap();
1258 let mut hypothesis = Hypothesis::with_id(
1259 "hypothesis:sqli",
1260 subject(),
1261 KnowledgePredicate::new("vulnerability", "sqli").unwrap(),
1262 EvidenceValue::Boolean(true),
1263 Probability::from_percent(50).unwrap(),
1264 )
1265 .unwrap();
1266 hypothesis
1267 .observe(
1268 BayesianEvidence::new(
1269 observation.id().clone(),
1270 Probability::from_percent(80).unwrap(),
1271 Probability::from_percent(20).unwrap(),
1272 "Boolean response difference",
1273 )
1274 .unwrap(),
1275 )
1276 .unwrap();
1277 hypothesis.set_strength(HypothesisStrength::Strong);
1278 hypothesis.set_state(HypothesisState::Supported);
1279 knowledge.upsert_hypothesis(hypothesis).unwrap();
1280 knowledge
1281 }
1282
1283 #[test]
1284 fn verification_case_preserves_optional_strategy_with_legacy_wire_compatibility() {
1285 let legacy = VerificationCase::new(
1286 "case:legacy",
1287 subject(),
1288 "legacy.observe",
1289 "hypothesis:sqli",
1290 )
1291 .unwrap();
1292 let legacy_wire = serde_json::to_value(&legacy).unwrap();
1293 assert!(legacy_wire.get("payload_strategy").is_none());
1294 assert!(legacy_wire.get("applies_hypothesis_transition").is_none());
1295 assert!(legacy_wire.get("payload_claim_policy_guard").is_none());
1296 let restored_legacy = serde_json::from_value::<VerificationCase>(legacy_wire).unwrap();
1297 assert!(restored_legacy.payload_strategy().is_none());
1298 assert!(restored_legacy.applies_hypothesis_transition());
1299 let mut misspelled = serde_json::to_value(&legacy).unwrap();
1300 misspelled["payload_stratgey"] = serde_json::json!({
1301 "id": "visibility.control-pair",
1302 "revision": 1
1303 });
1304 assert!(serde_json::from_value::<VerificationCase>(misspelled).is_err());
1305 let mut extended = serde_json::to_value(&legacy).unwrap();
1306 extended["future_extension"] = serde_json::json!({"accepted": true});
1307 assert!(serde_json::from_value::<VerificationCase>(extended).is_ok());
1308
1309 let strategy = PayloadStrategyRef::new("visibility.control-pair", 1).unwrap();
1310 let selected = legacy.clone().with_payload_strategy(Some(strategy.clone()));
1311 let restored: VerificationCase =
1312 serde_json::from_value(serde_json::to_value(&selected).unwrap()).unwrap();
1313 assert_eq!(restored, selected);
1314 assert_eq!(restored.payload_strategy(), Some(&strategy));
1315
1316 let knowledge_only = legacy.without_hypothesis_transition();
1317 let knowledge_only_wire = serde_json::to_value(&knowledge_only).unwrap();
1318 assert_eq!(knowledge_only_wire["applies_hypothesis_transition"], false);
1319 assert_eq!(knowledge_only_wire["payload_claim_policy_guard"], true);
1320 let mut unguarded = knowledge_only_wire.clone();
1321 unguarded
1322 .as_object_mut()
1323 .unwrap()
1324 .remove("payload_claim_policy_guard");
1325 assert!(serde_json::from_value::<VerificationCase>(unguarded).is_err());
1326 assert_eq!(
1327 serde_json::from_value::<VerificationCase>(knowledge_only_wire).unwrap(),
1328 knowledge_only
1329 );
1330
1331 let mut misspelled = serde_json::to_value(&restored_legacy).unwrap();
1332 misspelled["applies_hypothesis_transiton"] = serde_json::json!(false);
1333 assert!(serde_json::from_value::<VerificationCase>(misspelled).is_err());
1334
1335 let mut masquerading_policy = serde_json::to_value(&restored_legacy).unwrap();
1336 masquerading_policy["verification_target"] = serde_json::json!("knowledge_only");
1337 assert!(serde_json::from_value::<VerificationCase>(masquerading_policy.clone()).is_err());
1338
1339 #[cfg(feature = "scanning")]
1340 {
1341 let mut session_wire =
1342 serde_json::to_value(crate::DecisionSession::new(subject())).unwrap();
1343 session_wire["action_cycles"] = serde_json::json!(1);
1344 session_wire["state"] = serde_json::json!({
1345 "state": "awaiting_passive",
1346 "case": masquerading_policy
1347 });
1348 assert!(serde_json::from_value::<crate::DecisionSession>(session_wire).is_err());
1349 }
1350 }
1351
1352 fn case() -> VerificationCase {
1353 VerificationCase::new("case:sqli:1", subject(), "sqli.verify", "hypothesis:sqli").unwrap()
1354 }
1355
1356 fn rule(
1357 id: &str,
1358 stage: VerificationStage,
1359 priority: u16,
1360 predicate: KnowledgePredicate,
1361 outcome: OutcomeStatus,
1362 ) -> VerificationRule {
1363 VerificationRule::new(
1364 id,
1365 stage,
1366 priority,
1367 Expression::equals(
1368 KnowledgeLayer::Evidence,
1369 predicate,
1370 EvidenceValue::Boolean(true),
1371 ),
1372 outcome,
1373 Probability::from_percent(90).unwrap(),
1374 format!("{id} matched"),
1375 )
1376 .unwrap()
1377 }
1378
1379 #[test]
1380 fn scoped_rule_round_trips_and_rejects_non_evidence_case_scope() {
1381 let scoped = rule(
1382 "scoped",
1383 VerificationStage::Passive,
1384 10,
1385 boolean_predicate(),
1386 OutcomeStatus::Success,
1387 )
1388 .scoped_to_action("sqli.verify")
1389 .unwrap()
1390 .with_case_correlated_evidence()
1391 .unwrap();
1392 let serialized = serde_json::to_string(&scoped).unwrap();
1393 let restored: VerificationRule = serde_json::from_str(&serialized).unwrap();
1394
1395 assert_eq!(restored, scoped);
1396 assert_eq!(restored.action_id(), Some("sqli.verify"));
1397 assert!(restored.requires_case_correlated_evidence());
1398
1399 let hypothesis_rule = VerificationRule::new(
1400 "hypothesis-scoped",
1401 VerificationStage::Passive,
1402 10,
1403 Expression::exists(
1404 KnowledgeLayer::Hypothesis,
1405 KnowledgePredicate::new("vulnerability", "sqli").unwrap(),
1406 ),
1407 OutcomeStatus::Success,
1408 Probability::from_percent(90).unwrap(),
1409 "hypothesis exists",
1410 )
1411 .unwrap();
1412 assert!(matches!(
1413 hypothesis_rule.with_case_correlated_evidence(),
1414 Err(VerificationError::CaseCorrelationRequiresEvidenceOnly { .. })
1415 ));
1416 }
1417
1418 #[test]
1419 fn verification_rule_wire_rejects_scope_corruption() {
1420 let action_only = rule(
1421 "wire.scope.action",
1422 VerificationStage::Passive,
1423 10,
1424 boolean_predicate(),
1425 OutcomeStatus::Success,
1426 )
1427 .scoped_to_action("sqli.verify")
1428 .unwrap();
1429 assert_eq!(
1430 serde_json::to_value(&action_only).unwrap()["verification_scope_guard"],
1431 "action"
1432 );
1433 let case_only = rule(
1434 "wire.scope.case",
1435 VerificationStage::Passive,
1436 10,
1437 boolean_predicate(),
1438 OutcomeStatus::Success,
1439 )
1440 .with_case_correlated_evidence()
1441 .unwrap();
1442 assert_eq!(
1443 serde_json::to_value(&case_only).unwrap()["verification_scope_guard"],
1444 "case"
1445 );
1446
1447 let scoped = rule(
1448 "wire.scope.strict",
1449 VerificationStage::Passive,
1450 10,
1451 boolean_predicate(),
1452 OutcomeStatus::Success,
1453 )
1454 .scoped_to_action("sqli.verify")
1455 .unwrap()
1456 .with_case_correlated_evidence()
1457 .unwrap();
1458 let encoded = serde_json::to_value(&scoped).unwrap();
1459
1460 let mut legacy_guardless = encoded.clone();
1461 legacy_guardless
1462 .as_object_mut()
1463 .unwrap()
1464 .remove("verification_scope_guard");
1465 let restored_legacy = serde_json::from_value::<VerificationRule>(legacy_guardless).unwrap();
1466 assert_eq!(restored_legacy, scoped);
1467 assert_eq!(
1468 serde_json::to_value(&restored_legacy).unwrap()["verification_scope_guard"],
1469 "action_and_case"
1470 );
1471
1472 let mut verifier = PassiveVerifier::new();
1473 verifier.register(restored_legacy).unwrap();
1474 assert_eq!(
1475 verifier
1476 .verify(&knowledge(), &case())
1477 .unwrap()
1478 .outcome()
1479 .status(),
1480 OutcomeStatus::Unknown
1481 );
1482
1483 let mut misspelled_action = encoded.clone();
1484 let action_id = misspelled_action
1485 .as_object_mut()
1486 .unwrap()
1487 .remove("action_id")
1488 .unwrap();
1489 misspelled_action["action_idd"] = action_id;
1490 assert!(serde_json::from_value::<VerificationRule>(misspelled_action).is_err());
1491
1492 let mut misspelled_correlation = encoded.clone();
1493 let correlation = misspelled_correlation
1494 .as_object_mut()
1495 .unwrap()
1496 .remove("case_correlated_evidence")
1497 .unwrap();
1498 misspelled_correlation["case_correlated_evidnce"] = correlation;
1499 assert!(serde_json::from_value::<VerificationRule>(misspelled_correlation).is_err());
1500
1501 assert_eq!(encoded["verification_scope_guard"], "action_and_case");
1502 for field in ["action_id", "case_correlated_evidence"] {
1503 let mut missing = encoded.clone();
1504 missing.as_object_mut().unwrap().remove(field);
1505 assert!(serde_json::from_value::<VerificationRule>(missing).is_err());
1506 }
1507
1508 let mut inconsistent = encoded;
1509 inconsistent["verification_scope_guard"] = serde_json::json!("action");
1510 assert!(serde_json::from_value::<VerificationRule>(inconsistent).is_err());
1511 }
1512
1513 #[test]
1514 fn passive_verification_is_deterministic_and_uses_stable_ties() {
1515 let knowledge = knowledge();
1516 let mut verifier = PassiveVerifier::new();
1517 verifier
1518 .register(rule(
1519 "zeta",
1520 VerificationStage::Passive,
1521 10,
1522 boolean_predicate(),
1523 OutcomeStatus::NeedsReview,
1524 ))
1525 .unwrap();
1526 verifier
1527 .register(rule(
1528 "alpha",
1529 VerificationStage::Passive,
1530 10,
1531 boolean_predicate(),
1532 OutcomeStatus::Success,
1533 ))
1534 .unwrap();
1535
1536 let first = verifier.verify(&knowledge, &case()).unwrap();
1537 let second = verifier.verify(&knowledge, &case()).unwrap();
1538
1539 assert_eq!(first, second);
1540 assert_eq!(first.outcome().verifier_rule_id(), Some("alpha"));
1541 assert_eq!(first.outcome().status(), OutcomeStatus::Success);
1542 assert_eq!(
1543 first
1544 .evaluations()
1545 .iter()
1546 .filter(|evaluation| evaluation.selected())
1547 .count(),
1548 1
1549 );
1550 }
1551
1552 #[test]
1553 fn higher_priority_rule_resolves_conflicting_outcomes() {
1554 let knowledge = knowledge();
1555 let mut verifier = PassiveVerifier::new();
1556 verifier
1557 .register(rule(
1558 "success",
1559 VerificationStage::Passive,
1560 10,
1561 boolean_predicate(),
1562 OutcomeStatus::Success,
1563 ))
1564 .unwrap();
1565 verifier
1566 .register(rule(
1567 "review",
1568 VerificationStage::Passive,
1569 20,
1570 boolean_predicate(),
1571 OutcomeStatus::NeedsReview,
1572 ))
1573 .unwrap();
1574
1575 let report = verifier.verify(&knowledge, &case()).unwrap();
1576
1577 assert_eq!(report.outcome().status(), OutcomeStatus::NeedsReview);
1578 assert_eq!(report.outcome().verifier_rule_id(), Some("review"));
1579 }
1580
1581 #[test]
1582 fn active_verifier_requires_fresh_contributing_evidence() {
1583 let knowledge = knowledge();
1584 let baseline = knowledge.snapshot_for_subject(&subject());
1585 let mut verifier = ActiveVerifier::new();
1586 verifier
1587 .register(rule(
1588 "active.boolean",
1589 VerificationStage::Active,
1590 10,
1591 boolean_predicate(),
1592 OutcomeStatus::Success,
1593 ))
1594 .unwrap();
1595
1596 let stale = verifier
1597 .verify_snapshots(&case(), &baseline, &baseline)
1598 .unwrap();
1599 assert_eq!(stale.outcome().status(), OutcomeStatus::Unknown);
1600 assert!(stale.evaluations()[0].condition().matched());
1601 assert!(!stale.evaluations()[0].eligible());
1602
1603 knowledge
1604 .insert_evidence(evidence(
1605 boolean_predicate(),
1606 "active-boolean-control",
1607 true,
1608 ))
1609 .unwrap();
1610 let after_probe = knowledge.snapshot_for_subject(&subject());
1611 let verified = verifier
1612 .verify_snapshots(&case(), &baseline, &after_probe)
1613 .unwrap();
1614
1615 assert_eq!(verified.outcome().status(), OutcomeStatus::Success);
1616 assert_eq!(verified.evaluations()[0].fresh_evidence_ids().len(), 1);
1617 }
1618
1619 #[test]
1620 fn pipeline_escalates_review_to_active_confirmed_negative() {
1621 let knowledge = knowledge();
1622 let baseline = knowledge.snapshot_for_subject(&subject());
1623 let mut pipeline = VerificationPipeline::default();
1624 pipeline
1625 .passive_mut()
1626 .register(rule(
1627 "passive.review",
1628 VerificationStage::Passive,
1629 10,
1630 boolean_predicate(),
1631 OutcomeStatus::NeedsReview,
1632 ))
1633 .unwrap();
1634 pipeline
1635 .active_mut()
1636 .register(rule(
1637 "active.reject",
1638 VerificationStage::Active,
1639 10,
1640 timing_predicate(),
1641 OutcomeStatus::ConfirmedNegative,
1642 ))
1643 .unwrap();
1644
1645 let pending = pipeline.verify_snapshots(&case(), &baseline, None).unwrap();
1646 assert!(pending.requires_active());
1647 assert_eq!(pending.final_outcome().status(), OutcomeStatus::NeedsReview);
1648
1649 knowledge
1650 .insert_evidence(evidence(timing_predicate(), "time-control", true))
1651 .unwrap();
1652 let after_probe = knowledge.snapshot_for_subject(&subject());
1653 let completed = pipeline
1654 .verify_snapshots(&case(), &baseline, Some(&after_probe))
1655 .unwrap();
1656
1657 assert!(!completed.requires_active());
1658 assert_eq!(
1659 completed.final_outcome().status(),
1660 OutcomeStatus::ConfirmedNegative
1661 );
1662 assert_eq!(
1663 completed.active().unwrap().stage(),
1664 VerificationStage::Active
1665 );
1666 assert_eq!(
1667 apply_outcome(&knowledge, completed.final_outcome()).unwrap(),
1668 Some(KnowledgeWrite::Updated)
1669 );
1670 assert_eq!(
1671 knowledge.hypothesis("hypothesis:sqli").unwrap().state(),
1672 HypothesisState::Rejected
1673 );
1674 }
1675
1676 #[test]
1677 fn terminal_passive_outcome_skips_active_verifier() {
1678 let knowledge = knowledge();
1679 let snapshot = knowledge.snapshot_for_subject(&subject());
1680 let mut pipeline = VerificationPipeline::default();
1681 pipeline
1682 .passive_mut()
1683 .register(rule(
1684 "passive.success",
1685 VerificationStage::Passive,
1686 10,
1687 boolean_predicate(),
1688 OutcomeStatus::Success,
1689 ))
1690 .unwrap();
1691
1692 let report = pipeline
1693 .verify_snapshots(&case(), &snapshot, Some(&snapshot))
1694 .unwrap();
1695
1696 assert_eq!(report.final_outcome().status(), OutcomeStatus::Success);
1697 assert!(report.active().is_none());
1698 assert!(!report.requires_active());
1699 }
1700
1701 #[test]
1702 fn applying_conclusive_outcome_updates_hypothesis_once() {
1703 let knowledge = knowledge();
1704 let mut verifier = PassiveVerifier::new();
1705 verifier
1706 .register(rule(
1707 "passive.success",
1708 VerificationStage::Passive,
1709 10,
1710 boolean_predicate(),
1711 OutcomeStatus::Success,
1712 ))
1713 .unwrap();
1714 let report = verifier.verify(&knowledge, &case()).unwrap();
1715
1716 assert_eq!(
1717 apply_outcome(&knowledge, report.outcome()).unwrap(),
1718 Some(KnowledgeWrite::Updated)
1719 );
1720 assert_eq!(
1721 knowledge.hypothesis("hypothesis:sqli").unwrap().state(),
1722 HypothesisState::Confirmed
1723 );
1724 assert_eq!(
1725 apply_outcome(&knowledge, report.outcome()).unwrap(),
1726 Some(KnowledgeWrite::Unchanged)
1727 );
1728 }
1729
1730 #[test]
1731 fn knowledge_only_success_is_audited_without_transitioning_its_anchor() {
1732 let knowledge = knowledge();
1733 let mut verifier = PassiveVerifier::new();
1734 verifier
1735 .register(rule(
1736 "passive.success",
1737 VerificationStage::Passive,
1738 10,
1739 boolean_predicate(),
1740 OutcomeStatus::Success,
1741 ))
1742 .unwrap();
1743 let knowledge_only = case().without_hypothesis_transition();
1744 let report = verifier.verify(&knowledge, &knowledge_only).unwrap();
1745
1746 assert_eq!(report.outcome().status(), OutcomeStatus::Success);
1747 assert_eq!(report.apply(&knowledge).unwrap(), None);
1748 assert_eq!(
1749 knowledge.hypothesis("hypothesis:sqli").unwrap().state(),
1750 HypothesisState::Supported
1751 );
1752
1753 let stale_report = verifier.verify(&knowledge, &knowledge_only).unwrap();
1754 knowledge
1755 .insert_evidence(evidence(timing_predicate(), "late-observation", true))
1756 .unwrap();
1757 assert!(matches!(
1758 stale_report.apply(&knowledge),
1759 Err(VerificationError::Knowledge(
1760 KnowledgeBaseError::StaleSnapshot { .. }
1761 ))
1762 ));
1763 assert_eq!(
1764 knowledge.hypothesis("hypothesis:sqli").unwrap().state(),
1765 HypothesisState::Supported
1766 );
1767 }
1768
1769 #[test]
1770 fn report_application_rejects_a_stale_hypothesis_evaluation() {
1771 let knowledge = knowledge();
1772 let mut verifier = PassiveVerifier::new();
1773 verifier
1774 .register(rule(
1775 "passive.success",
1776 VerificationStage::Passive,
1777 10,
1778 boolean_predicate(),
1779 OutcomeStatus::Success,
1780 ))
1781 .unwrap();
1782 let report = verifier.verify(&knowledge, &case()).unwrap();
1783 let serialized = serde_json::to_value(&report).unwrap();
1784 assert!(serialized.get("commit_token").is_none());
1785
1786 let mut recalibrated = knowledge.hypothesis("hypothesis:sqli").unwrap();
1787 recalibrated.set_strength(HypothesisStrength::Weak);
1788 knowledge.upsert_hypothesis(recalibrated).unwrap();
1789
1790 assert!(matches!(
1791 report.apply(&knowledge),
1792 Err(VerificationError::Knowledge(
1793 KnowledgeBaseError::StaleSnapshot { .. }
1794 ))
1795 ));
1796 let stored = knowledge.hypothesis("hypothesis:sqli").unwrap();
1797 assert_eq!(stored.state(), HypothesisState::Supported);
1798 assert_eq!(stored.strength(), HypothesisStrength::Weak);
1799 }
1800
1801 #[test]
1802 fn nonterminal_report_application_still_rejects_stale_knowledge() {
1803 let knowledge = knowledge();
1804 let report = PassiveVerifier::new().verify(&knowledge, &case()).unwrap();
1805 assert_eq!(report.outcome().status(), OutcomeStatus::Unknown);
1806 assert_eq!(report.apply(&knowledge).unwrap(), None);
1807
1808 knowledge
1809 .insert_evidence(evidence(timing_predicate(), "late-observation", true))
1810 .unwrap();
1811
1812 assert!(matches!(
1813 report.apply(&knowledge),
1814 Err(VerificationError::Knowledge(
1815 KnowledgeBaseError::StaleSnapshot { .. }
1816 ))
1817 ));
1818 }
1819
1820 #[test]
1821 fn report_application_is_idempotent_and_rejects_opposite_terminal_state() {
1822 let knowledge = knowledge();
1823 let mut confirming = PassiveVerifier::new();
1824 confirming
1825 .register(rule(
1826 "passive.success",
1827 VerificationStage::Passive,
1828 10,
1829 boolean_predicate(),
1830 OutcomeStatus::Success,
1831 ))
1832 .unwrap();
1833 let mut rejecting = PassiveVerifier::new();
1834 rejecting
1835 .register(rule(
1836 "passive.false-positive",
1837 VerificationStage::Passive,
1838 10,
1839 boolean_predicate(),
1840 OutcomeStatus::FalsePositive,
1841 ))
1842 .unwrap();
1843 let confirmed = confirming.verify(&knowledge, &case()).unwrap();
1844 let rejected = rejecting.verify(&knowledge, &case()).unwrap();
1845
1846 assert_eq!(
1847 confirmed.apply(&knowledge).unwrap(),
1848 Some(KnowledgeWrite::Updated)
1849 );
1850 assert_eq!(
1851 confirmed.apply(&knowledge).unwrap(),
1852 Some(KnowledgeWrite::Unchanged)
1853 );
1854 assert!(matches!(
1855 rejected.apply(&knowledge),
1856 Err(VerificationError::ConflictingTerminalState {
1857 hypothesis_id,
1858 current: HypothesisState::Confirmed,
1859 attempted: HypothesisState::Rejected,
1860 }) if hypothesis_id == "hypothesis:sqli"
1861 ));
1862 assert_eq!(
1863 knowledge.hypothesis("hypothesis:sqli").unwrap().state(),
1864 HypothesisState::Confirmed
1865 );
1866 }
1867
1868 #[test]
1869 fn rule_wire_and_stage_invariants_are_enforced() {
1870 assert!(matches!(
1871 VerificationRule::new(
1872 "passive.negative",
1873 VerificationStage::Passive,
1874 10,
1875 Expression::equals(
1876 KnowledgeLayer::Evidence,
1877 boolean_predicate(),
1878 EvidenceValue::Boolean(true),
1879 ),
1880 OutcomeStatus::ConfirmedNegative,
1881 Probability::from_percent(95).unwrap(),
1882 "Passive evidence cannot establish a confirmed negative",
1883 ),
1884 Err(VerificationError::ConfirmedNegativeRequiresActive { .. })
1885 ));
1886 let rule = rule(
1887 "passive.success",
1888 VerificationStage::Passive,
1889 10,
1890 boolean_predicate(),
1891 OutcomeStatus::Success,
1892 );
1893 let mut encoded = serde_json::to_value(&rule).unwrap();
1894 assert_eq!(
1895 serde_json::from_value::<VerificationRule>(encoded.clone()).unwrap(),
1896 rule
1897 );
1898 encoded["outcome"] = serde_json::json!("unknown");
1899 assert!(serde_json::from_value::<VerificationRule>(encoded).is_err());
1900
1901 let mut active = ActiveVerifier::new();
1902 assert!(matches!(
1903 active.register(rule),
1904 Err(VerificationError::WrongStage { .. })
1905 ));
1906 }
1907
1908 #[test]
1909 fn active_snapshots_must_share_authority() {
1910 let baseline_knowledge = knowledge();
1911 let after_knowledge = knowledge();
1912 let baseline = baseline_knowledge.snapshot_for_subject(&subject());
1913 let after = after_knowledge.snapshot_for_subject(&subject());
1914 let verifier = ActiveVerifier::new();
1915
1916 assert!(matches!(
1917 verifier.verify_snapshots(&case(), &baseline, &after),
1918 Err(VerificationError::Knowledge(
1919 KnowledgeBaseError::SnapshotAuthorityMismatch { .. }
1920 ))
1921 ));
1922 }
1923
1924 #[test]
1925 fn active_snapshot_must_preserve_same_authority_baseline_evidence() {
1926 let knowledge = knowledge();
1927 let before = knowledge.snapshot_for_subject(&subject());
1928 knowledge
1929 .insert_evidence(evidence(timing_predicate(), "later", true))
1930 .unwrap();
1931 let after = knowledge.snapshot_for_subject(&subject());
1932 let verifier = ActiveVerifier::new();
1933
1934 assert!(matches!(
1935 verifier.verify_snapshots(&case(), &after, &before),
1936 Err(VerificationError::NonMonotonicSnapshot { .. })
1937 ));
1938 }
1939}