Skip to main content

termivar_scanner/
verification.rs

1//! Deterministic passive and active verification.
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//! Verifiers consume immutable knowledge snapshots and never execute probes.
13//! Passive rules may use existing evidence. Active rules are eligible only
14//! when their expression cites evidence added after the passive snapshot, so
15//! probe execution remains an explicit boundary outside the decision engine.
16
17use 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/// Validation and evaluation errors raised by verification components.
36#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum VerificationError {
39    /// A required case, rule, or explanation value was empty.
40    #[error("{field} must not be empty")]
41    EmptyValue { field: &'static str },
42
43    /// A rule attempted to explicitly emit the evidence-free fallback status.
44    #[error("verification rule {rule_id} cannot emit the reserved unknown status")]
45    ReservedUnknownStatus { rule_id: String },
46
47    /// A conclusive rule was configured with zero confidence.
48    #[error("verification rule {rule_id} must have non-zero confidence")]
49    ZeroConfidence { rule_id: String },
50
51    /// A confirmed-negative rule was configured for passive evaluation.
52    #[error("verification rule {rule_id} must use the active stage for confirmed negatives")]
53    ConfirmedNegativeRequiresActive { rule_id: String },
54
55    /// Case-correlated evaluation was requested for a non-evidence expression.
56    #[error(
57        "verification rule {rule_id} must use only raw evidence when case correlation is required"
58    )]
59    CaseCorrelationRequiresEvidenceOnly { rule_id: String },
60
61    /// A verifier was given a rule for the other evidence collection stage.
62    #[error("verification rule {rule_id} belongs to {actual:?}, expected {expected:?}")]
63    WrongStage {
64        /// Rule with the incompatible stage.
65        rule_id: String,
66        /// Stage owned by the verifier.
67        expected: VerificationStage,
68        /// Stage declared by the rule.
69        actual: VerificationStage,
70    },
71
72    /// A rule identity was reused with different semantics.
73    #[error("verification rule identity {id} already has a different definition")]
74    RuleIdentityConflict { id: String },
75
76    /// The case and snapshot refer to different subjects.
77    #[error("verification case subject {expected} does not match snapshot subject {actual}")]
78    SnapshotSubjectMismatch {
79        /// Subject declared by the verification case.
80        expected: EntityId,
81        /// Subject captured by the snapshot.
82        actual: EntityId,
83    },
84
85    /// The case references a hypothesis absent from the snapshot or knowledge base.
86    #[error("verification hypothesis {hypothesis_id} was not found")]
87    UnknownHypothesis { hypothesis_id: String },
88
89    /// A matched rule relied only on absence or ontology and cited no observation.
90    #[error("matched verification rule {rule_id} did not cite any evidence")]
91    MissingContributingEvidence { rule_id: String },
92
93    /// An active snapshot omitted evidence that existed before the probe.
94    #[error("active snapshot is missing baseline evidence {evidence_id}")]
95    NonMonotonicSnapshot { evidence_id: EvidenceId },
96
97    /// A persisted outcome referenced evidence absent from the knowledge base.
98    #[error("verification evidence {evidence_id} was not found")]
99    UnknownEvidence { evidence_id: EvidenceId },
100
101    /// Outcome evidence belongs to a different subject.
102    #[error("verification evidence {evidence_id} does not belong to subject {subject}")]
103    EvidenceSubjectMismatch {
104        /// Evidence with incompatible provenance.
105        evidence_id: EvidenceId,
106        /// Subject declared by the outcome.
107        subject: EntityId,
108    },
109
110    /// A verifier attempted to reverse an existing terminal conclusion.
111    #[error(
112        "verification cannot change terminal hypothesis {hypothesis_id} from {current:?} to {attempted:?}"
113    )]
114    ConflictingTerminalState {
115        /// Hypothesis protected by the terminal transition.
116        hypothesis_id: String,
117        /// Terminal state already stored.
118        current: HypothesisState,
119        /// Opposite terminal state requested by the outcome.
120        attempted: HypothesisState,
121    },
122
123    /// Expression evaluation failed.
124    #[error(transparent)]
125    Rule(#[from] RuleEngineError),
126
127    /// Outcome construction failed.
128    #[error(transparent)]
129    Outcome(#[from] OutcomeError),
130
131    /// A hypothesis update conflicted with stored knowledge.
132    #[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/// Stable identity linking a planned action to its hypothesis provenance.
168///
169/// Most cases authorize a conclusive outcome to transition `hypothesis_id`.
170/// Knowledge-only cases retain that identity solely as the motivation/audit
171/// anchor and explicitly suppress every hypothesis-state transition.
172#[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    // Legacy readers already reject unknown `payload_*` fields. Emitting this
183    // guard for knowledge-only cases prevents them from ignoring the new policy
184    // bit and reconstructing a transition-authorized case.
185    #[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    /// Creates a validated verification case.
195    ///
196    /// By default a conclusive outcome transitions [`Self::hypothesis_id`]. Use
197    /// [`Self::without_hypothesis_transition`] for a knowledge-only case whose
198    /// outcome is recorded without confirming or rejecting any hypothesis.
199    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    /// Attaches the exact planner-selected strategy revision to this case.
217    pub fn with_payload_strategy(mut self, strategy: Option<PayloadStrategyRef>) -> Self {
218        self.payload_strategy = strategy;
219        self
220    }
221
222    /// Marks this case knowledge-only: its outcome is still recorded (and can be
223    /// `Success`), but no hypothesis-state transition is applied. This separates
224    /// "the action's objective was achieved" from "the motivating hypothesis was
225    /// conclusively verified".
226    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    /// Returns whether a conclusive outcome transitions the case hypothesis.
233    pub fn applies_hypothesis_transition(&self) -> bool {
234        self.applies_hypothesis_transition
235    }
236
237    /// Returns the stable case identity.
238    pub fn id(&self) -> &str {
239        &self.id
240    }
241
242    /// Returns the subject being verified.
243    pub fn subject(&self) -> &EntityId {
244        &self.subject
245    }
246
247    /// Returns the planner action that opened the case.
248    pub fn action_id(&self) -> &str {
249        &self.action_id
250    }
251
252    /// Returns the transition target, or the motivation/audit anchor when this
253    /// case does not authorize hypothesis transitions.
254    pub fn hypothesis_id(&self) -> &str {
255        &self.hypothesis_id
256    }
257
258    /// Returns the payload strategy selected for this case, when present.
259    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/// Declarative evidence expression mapped to one non-unknown outcome.
312#[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    /// Creates a validated verifier rule.
331    #[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    /// Restricts this rule to verification cases opened by one action.
366    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    /// Restricts raw evidence evaluation to the current case correlation ID.
376    ///
377    /// Case correlation is valid only for evidence-layer expressions. Facts,
378    /// hypotheses, and ontology claims are not produced by one executor case.
379    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    /// Returns the stable rule identity.
398    pub fn id(&self) -> &str {
399        &self.id
400    }
401
402    /// Returns the evidence collection stage owned by this rule.
403    pub fn stage(&self) -> VerificationStage {
404        self.stage
405    }
406
407    /// Returns the deterministic conflict-resolution priority.
408    pub fn priority(&self) -> u16 {
409        self.priority
410    }
411
412    /// Returns the evidence expression.
413    pub fn condition(&self) -> &Expression {
414        &self.condition
415    }
416
417    /// Returns the classification emitted when the expression wins.
418    pub fn outcome(&self) -> OutcomeStatus {
419        self.outcome
420    }
421
422    /// Returns the calibrated confidence assigned to this rule.
423    pub fn confidence(&self) -> Probability {
424        self.confidence
425    }
426
427    /// Returns the rule's human-readable explanation.
428    pub fn rationale(&self) -> &str {
429        &self.rationale
430    }
431
432    /// Returns the action identity required by this rule, if scoped.
433    pub fn action_id(&self) -> Option<&str> {
434        self.action_id.as_deref()
435    }
436
437    /// Returns whether raw evidence is limited to the current case correlation.
438    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/// Result of registering a verifier rule identity.
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505#[non_exhaustive]
506pub enum VerifierWrite {
507    /// A new rule was registered.
508    Inserted,
509    /// The identical rule was already registered.
510    Unchanged,
511}
512
513/// Explainable evaluation of one verifier rule.
514#[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    /// Returns the evaluated rule identity.
528    pub fn rule_id(&self) -> &str {
529        &self.rule_id
530    }
531
532    /// Returns the rule's evidence collection stage.
533    pub fn stage(&self) -> VerificationStage {
534        self.stage
535    }
536
537    /// Returns the rule priority used for conflict resolution.
538    pub fn priority(&self) -> u16 {
539        self.priority
540    }
541
542    /// Returns the complete expression evaluation trace.
543    pub fn condition(&self) -> &ExpressionEvaluation {
544        &self.condition
545    }
546
547    /// Returns contributing evidence absent from the passive baseline.
548    pub fn fresh_evidence_ids(&self) -> &BTreeSet<EvidenceId> {
549        &self.fresh_evidence_ids
550    }
551
552    /// Returns whether the case action satisfied this rule's action scope.
553    pub fn action_matched(&self) -> bool {
554        self.action_matched
555    }
556
557    /// Returns whether this rule could participate in winner selection.
558    pub fn eligible(&self) -> bool {
559        self.eligible
560    }
561
562    /// Returns whether this rule produced the report outcome.
563    pub fn selected(&self) -> bool {
564        self.selected
565    }
566}
567
568/// Outcome and audit trail for one verification stage.
569#[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
586// The opaque authority is an application guard, not part of a report's stable
587// semantic value. Equivalent reports from independent runs remain comparable,
588// while `apply` still checks pointer identity explicitly.
589impl 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    /// Returns the verified case.
600    pub fn case(&self) -> &VerificationCase {
601        &self.case
602    }
603
604    /// Returns the evaluated evidence collection stage.
605    pub fn stage(&self) -> VerificationStage {
606        self.stage
607    }
608
609    /// Returns the stage outcome.
610    pub fn outcome(&self) -> &Outcome {
611        &self.outcome
612    }
613
614    /// Returns rule evaluations in stable rule-ID order.
615    pub fn evaluations(&self) -> &[VerificationRuleEvaluation] {
616        &self.evaluations
617    }
618
619    /// Projects the hypothesis state that a successful [`Self::apply`] would
620    /// expose to follow-on planner authorization without mutating knowledge.
621    ///
622    /// Decision-loop transitions are prepared before the verifier CAS commits.
623    /// Using this projection prevents adaptive scheduling from authorizing an
624    /// action against a hypothesis that the same outcome is about to reject,
625    /// while preserving error-atomic session, Experience, and knowledge writes.
626    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    /// Applies this report's authorized state transition with snapshot CAS.
644    ///
645    /// The report is bound to the subject and ontology revisions used for its
646    /// evaluation. If rule-visible knowledge changed before the transition, the
647    /// complete state update is rejected as stale. Audit-only nonterminal reports
648    /// are validated too, because their callers may still commit adaptive or
649    /// session decisions. Replaying an already-applied terminal report is
650    /// idempotent; an opposite terminal result is rejected. A knowledge-only
651    /// case validates the same snapshot token but returns `None` without
652    /// transitioning its audit-anchor hypothesis.
653    pub fn apply(
654        &self,
655        knowledge: &KnowledgeBase,
656    ) -> Result<Option<KnowledgeWrite>, VerificationError> {
657        if !self.case.applies_hypothesis_transition() {
658            // Knowledge-only case: record the outcome without confirming or
659            // rejecting any hypothesis. The snapshot revisions are still
660            // validated (as for any audit-only report) so callers that commit
661            // adaptive or session decisions observe a fresh snapshot.
662            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/// Pure verifier for evidence collected without an additional probe.
722///
723/// # Example
724///
725/// ```rust
726/// use termivar_core::{
727///     EvidenceValue, KnowledgePredicate, OutcomeStatus, Probability, VerificationStage,
728/// };
729/// use termivar_scanner::{
730///     Expression, KnowledgeLayer, PassiveVerifier, VerificationRule, VerifierWrite,
731/// };
732///
733/// let rule = VerificationRule::new(
734///     "verify.boolean-difference",
735///     VerificationStage::Passive,
736///     100,
737///     Expression::equals(
738///         KnowledgeLayer::Evidence,
739///         KnowledgePredicate::new("verification", "boolean_difference")?,
740///         EvidenceValue::Boolean(true),
741///     ),
742///     OutcomeStatus::Success,
743///     Probability::from_percent(95)?,
744///     "Boolean responses diverged consistently",
745/// )?;
746/// let mut verifier = PassiveVerifier::new();
747///
748/// assert_eq!(verifier.register(rule)?, VerifierWrite::Inserted);
749/// # Ok::<(), Box<dyn std::error::Error>>(())
750/// ```
751#[derive(Debug, Clone)]
752pub struct PassiveVerifier {
753    registry: RuleRegistry,
754}
755
756impl PassiveVerifier {
757    /// Creates an empty passive verifier.
758    pub fn new() -> Self {
759        Self {
760            registry: RuleRegistry::new(VerificationStage::Passive),
761        }
762    }
763
764    /// Registers one passive rule idempotently.
765    pub fn register(&mut self, rule: VerificationRule) -> Result<VerifierWrite, VerificationError> {
766        self.registry.register(rule)
767    }
768
769    /// Returns the number of passive rules.
770    pub fn len(&self) -> usize {
771        self.registry.len()
772    }
773
774    /// Returns whether no passive rules are registered.
775    pub fn is_empty(&self) -> bool {
776        self.registry.is_empty()
777    }
778
779    /// Verifies a case from one internally consistent knowledge snapshot.
780    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    /// Verifies a case against an explicit immutable snapshot.
790    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/// Pure verifier for evidence collected by an explicit active probe.
806///
807/// This type does not send traffic. The caller executes a planned probe,
808/// records its observations through the evidence engine, and supplies the
809/// before/after snapshots. A matching active rule must cite at least one new
810/// evidence ID.
811#[derive(Debug, Clone)]
812pub struct ActiveVerifier {
813    registry: RuleRegistry,
814}
815
816impl ActiveVerifier {
817    /// Creates an empty active verifier.
818    pub fn new() -> Self {
819        Self {
820            registry: RuleRegistry::new(VerificationStage::Active),
821        }
822    }
823
824    /// Registers one active rule idempotently.
825    pub fn register(&mut self, rule: VerificationRule) -> Result<VerifierWrite, VerificationError> {
826        self.registry.register(rule)
827    }
828
829    /// Returns the number of active rules.
830    pub fn len(&self) -> usize {
831        self.registry.len()
832    }
833
834    /// Returns whether no active rules are registered.
835    pub fn is_empty(&self) -> bool {
836        self.registry.is_empty()
837    }
838
839    /// Verifies a case using a baseline and post-probe snapshot.
840    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/// Ordered passive-to-active verification pipeline.
859#[derive(Debug, Clone, Default)]
860pub struct VerificationPipeline {
861    passive: PassiveVerifier,
862    active: ActiveVerifier,
863}
864
865impl VerificationPipeline {
866    /// Creates a pipeline from independently configured verifiers.
867    pub fn new(passive: PassiveVerifier, active: ActiveVerifier) -> Self {
868        Self { passive, active }
869    }
870
871    /// Returns the passive verifier registry.
872    pub fn passive(&self) -> &PassiveVerifier {
873        &self.passive
874    }
875
876    /// Returns the mutable passive verifier registry.
877    pub fn passive_mut(&mut self) -> &mut PassiveVerifier {
878        &mut self.passive
879    }
880
881    /// Returns the active verifier registry.
882    pub fn active(&self) -> &ActiveVerifier {
883        &self.active
884    }
885
886    /// Returns the mutable active verifier registry.
887    pub fn active_mut(&mut self) -> &mut ActiveVerifier {
888        &mut self.active
889    }
890
891    /// Evaluates passive rules and optionally a post-probe active snapshot.
892    ///
893    /// Terminal passive outcomes never reach the active verifier. `Unknown`
894    /// and `NeedsReview` request active verification when no active snapshot is
895    /// supplied.
896    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/// Full passive/active audit trail for one verification case.
918#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
919pub struct VerificationPipelineReport {
920    passive: VerificationReport,
921    active: Option<VerificationReport>,
922}
923
924impl VerificationPipelineReport {
925    /// Returns the passive stage report.
926    pub fn passive(&self) -> &VerificationReport {
927        &self.passive
928    }
929
930    /// Returns the active report when post-probe evidence was evaluated.
931    pub fn active(&self) -> Option<&VerificationReport> {
932        self.active.as_ref()
933    }
934
935    /// Returns the most recent outcome in the pipeline.
936    pub fn final_outcome(&self) -> &Outcome {
937        self.active
938            .as_ref()
939            .map_or_else(|| self.passive.outcome(), VerificationReport::outcome)
940    }
941
942    /// Returns whether an unresolved passive result still needs active evidence.
943    pub fn requires_active(&self) -> bool {
944        !self.passive.outcome().status().is_terminal() && self.active.is_none()
945    }
946}
947
948/// Applies verifier-owned hypothesis state transitions to the knowledge base.
949///
950/// `Success` confirms a hypothesis. `FalsePositive` and `ConfirmedNegative`
951/// reject it. Other outcomes are audit records only and leave hypothesis state
952/// unchanged.
953///
954/// This compatibility entry point has no [`VerificationCase`], so it cannot
955/// enforce a knowledge-only case's transition policy. Call it only for outcomes
956/// already authorized to transition their hypothesis. It also has no snapshot
957/// token and therefore cannot detect recalibration between verification and
958/// application. Terminal transitions remain monotonic: same-state replay is
959/// idempotent and an opposite terminal transition is rejected. Prefer
960/// [`VerificationReport::apply`] whenever the report is available.
961pub 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 = &registry.rules[left];
1112        let right_rule = &registry.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 = &registry.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}