Skip to main content

termivar_core/
outcome.rs

1//! Transport-neutral verification outcomes.
2//!
3//! Outcomes are deterministic decision records. They reference immutable
4//! evidence but contain no probing, scheduling, or plugin execution behavior.
5
6use std::collections::BTreeSet;
7
8use serde::{Deserialize, Deserializer, Serialize};
9use thiserror::Error;
10
11use crate::reasoning::{EntityId, EvidenceId, HypothesisState, Probability};
12
13/// Validation errors for verification outcome contracts.
14#[derive(Debug, Clone, PartialEq, Eq, Error)]
15#[non_exhaustive]
16pub enum OutcomeError {
17    /// A required identity or explanation was empty.
18    #[error("{field} must not be empty")]
19    EmptyValue { field: &'static str },
20
21    /// `Unknown` is reserved for the canonical evidence-free fallback.
22    #[error("unknown outcomes must be created without a verifier rule or evidence")]
23    InvalidUnknown,
24
25    /// A trusted negative conclusion was emitted without an active control.
26    #[error("confirmed negative outcomes require active verification evidence")]
27    ConfirmedNegativeRequiresActive,
28
29    /// A conclusive outcome did not reference any evidence.
30    #[error("outcome {status:?} must reference at least one evidence record")]
31    MissingEvidence { status: OutcomeStatus },
32
33    /// A conclusive outcome had zero confidence.
34    #[error("outcome {status:?} must have non-zero confidence")]
35    ZeroConfidence { status: OutcomeStatus },
36}
37
38fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, OutcomeError> {
39    let value = value.into();
40    if value.trim().is_empty() {
41        return Err(OutcomeError::EmptyValue { field });
42    }
43    Ok(value)
44}
45
46/// Evidence collection stage that produced a verification outcome.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum VerificationStage {
51    /// Existing evidence was evaluated without sending a new probe.
52    Passive,
53    /// Evidence produced by an explicit verification probe was evaluated.
54    Active,
55}
56
57impl VerificationStage {
58    /// Returns the stable wire name for the stage.
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Passive => "passive",
62            Self::Active => "active",
63        }
64    }
65}
66
67/// Final classification emitted by a verifier.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70#[non_exhaustive]
71pub enum OutcomeStatus {
72    /// The verifier's success condition was satisfied.
73    ///
74    /// Whether that success may transition the associated hypothesis is an
75    /// orchestration policy carried outside this core outcome value.
76    Success,
77    /// Verification could not proceed because the target or a control blocked it.
78    Blocked,
79    /// Current evidence cannot support a deterministic conclusion.
80    Unknown,
81    /// Verification rejected the hypothesis.
82    FalsePositive,
83    /// Conflicting or incomplete evidence requires a human decision.
84    NeedsReview,
85    /// An audited negative control explicitly disproved the hypothesis.
86    ///
87    /// Unlike `FalsePositive`, this records a trusted negative result rather
88    /// than a verifier rejecting the provenance or interpretation of a prior
89    /// finding.
90    ConfirmedNegative,
91}
92
93impl OutcomeStatus {
94    /// Returns whether the verification pipeline should stop at this status.
95    pub const fn is_terminal(self) -> bool {
96        matches!(
97            self,
98            Self::Success | Self::Blocked | Self::FalsePositive | Self::ConfirmedNegative
99        )
100    }
101
102    /// Returns the candidate verifier-owned hypothesis transition state
103    /// associated with this status.
104    ///
105    /// Verification-case and orchestration policy decide whether that candidate
106    /// state is actually applied.
107    pub const fn hypothesis_state(self) -> Option<HypothesisState> {
108        match self {
109            Self::Success => Some(HypothesisState::Confirmed),
110            Self::FalsePositive | Self::ConfirmedNegative => Some(HypothesisState::Rejected),
111            Self::Blocked | Self::Unknown | Self::NeedsReview => None,
112        }
113    }
114}
115
116/// Immutable, evidence-backed result of evaluating one planned action.
117///
118/// `Unknown` is canonical: it has zero confidence, no rule identity, and no
119/// evidence. Every other status must identify the winning verifier rule and
120/// carry at least one immutable evidence reference.
121///
122/// # Example
123///
124/// ```rust
125/// use std::collections::BTreeSet;
126/// use termivar_core::{
127///     EntityId, EvidenceId, Outcome, OutcomeStatus, Probability, VerificationStage,
128/// };
129///
130/// let outcome = Outcome::verified(
131///     "case:sqli:1",
132///     EntityId::new("endpoint:https://example.test")?,
133///     "sqli.verify",
134///     "hypothesis:sqli:1",
135///     "verify.boolean-difference",
136///     VerificationStage::Passive,
137///     OutcomeStatus::Success,
138///     Probability::from_percent(95)?,
139///     "Boolean responses diverged consistently",
140///     BTreeSet::from([EvidenceId::parse("evidence:boolean-difference")?]),
141/// )?;
142///
143/// assert_eq!(outcome.status(), OutcomeStatus::Success);
144/// # Ok::<(), Box<dyn std::error::Error>>(())
145/// ```
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct Outcome {
148    case_id: String,
149    subject: EntityId,
150    action_id: String,
151    hypothesis_id: String,
152    verifier_rule_id: Option<String>,
153    stage: VerificationStage,
154    status: OutcomeStatus,
155    confidence: Probability,
156    rationale: String,
157    evidence_ids: BTreeSet<EvidenceId>,
158}
159
160impl Outcome {
161    /// Creates an evidence-backed non-unknown outcome.
162    #[allow(clippy::too_many_arguments)]
163    pub fn verified(
164        case_id: impl Into<String>,
165        subject: EntityId,
166        action_id: impl Into<String>,
167        hypothesis_id: impl Into<String>,
168        verifier_rule_id: impl Into<String>,
169        stage: VerificationStage,
170        status: OutcomeStatus,
171        confidence: Probability,
172        rationale: impl Into<String>,
173        evidence_ids: BTreeSet<EvidenceId>,
174    ) -> Result<Self, OutcomeError> {
175        if status == OutcomeStatus::Unknown {
176            return Err(OutcomeError::InvalidUnknown);
177        }
178        if status == OutcomeStatus::ConfirmedNegative && stage != VerificationStage::Active {
179            return Err(OutcomeError::ConfirmedNegativeRequiresActive);
180        }
181        if evidence_ids.is_empty() {
182            return Err(OutcomeError::MissingEvidence { status });
183        }
184        if confidence == Probability::ZERO {
185            return Err(OutcomeError::ZeroConfidence { status });
186        }
187        Ok(Self {
188            case_id: non_empty(case_id, "verification case id")?,
189            subject,
190            action_id: non_empty(action_id, "verification action id")?,
191            hypothesis_id: non_empty(hypothesis_id, "verification hypothesis id")?,
192            verifier_rule_id: Some(non_empty(verifier_rule_id, "verifier rule id")?),
193            stage,
194            status,
195            confidence,
196            rationale: non_empty(rationale, "verification rationale")?,
197            evidence_ids,
198        })
199    }
200
201    /// Creates the canonical fallback used when no verifier rule is eligible.
202    pub fn unknown(
203        case_id: impl Into<String>,
204        subject: EntityId,
205        action_id: impl Into<String>,
206        hypothesis_id: impl Into<String>,
207        stage: VerificationStage,
208        rationale: impl Into<String>,
209    ) -> Result<Self, OutcomeError> {
210        Ok(Self {
211            case_id: non_empty(case_id, "verification case id")?,
212            subject,
213            action_id: non_empty(action_id, "verification action id")?,
214            hypothesis_id: non_empty(hypothesis_id, "verification hypothesis id")?,
215            verifier_rule_id: None,
216            stage,
217            status: OutcomeStatus::Unknown,
218            confidence: Probability::ZERO,
219            rationale: non_empty(rationale, "verification rationale")?,
220            evidence_ids: BTreeSet::new(),
221        })
222    }
223
224    /// Returns the stable verification case identity.
225    pub fn case_id(&self) -> &str {
226        &self.case_id
227    }
228
229    /// Returns the entity evaluated by this outcome.
230    pub fn subject(&self) -> &EntityId {
231        &self.subject
232    }
233
234    /// Returns the planned action identity that opened this case.
235    pub fn action_id(&self) -> &str {
236        &self.action_id
237    }
238
239    /// Returns the hypothesis associated with this outcome's case.
240    ///
241    /// The host orchestration contract decides whether a conclusive result may
242    /// transition this hypothesis.
243    pub fn hypothesis_id(&self) -> &str {
244        &self.hypothesis_id
245    }
246
247    /// Returns the winning verifier rule, if one matched.
248    pub fn verifier_rule_id(&self) -> Option<&str> {
249        self.verifier_rule_id.as_deref()
250    }
251
252    /// Returns the evidence collection stage.
253    pub fn stage(&self) -> VerificationStage {
254        self.stage
255    }
256
257    /// Returns the final classification.
258    pub fn status(&self) -> OutcomeStatus {
259        self.status
260    }
261
262    /// Returns the rule-assigned calibrated confidence.
263    pub fn confidence(&self) -> Probability {
264        self.confidence
265    }
266
267    /// Returns the human-readable decision explanation.
268    pub fn rationale(&self) -> &str {
269        &self.rationale
270    }
271
272    /// Returns immutable evidence supporting the decision.
273    pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
274        &self.evidence_ids
275    }
276}
277
278impl<'de> Deserialize<'de> for Outcome {
279    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
280    where
281        D: Deserializer<'de>,
282    {
283        #[derive(Deserialize)]
284        struct WireOutcome {
285            case_id: String,
286            subject: EntityId,
287            action_id: String,
288            hypothesis_id: String,
289            verifier_rule_id: Option<String>,
290            stage: VerificationStage,
291            status: OutcomeStatus,
292            confidence: Probability,
293            rationale: String,
294            evidence_ids: BTreeSet<EvidenceId>,
295        }
296
297        let wire = WireOutcome::deserialize(deserializer)?;
298        if wire.status == OutcomeStatus::Unknown {
299            if wire.verifier_rule_id.is_some()
300                || wire.confidence != Probability::ZERO
301                || !wire.evidence_ids.is_empty()
302            {
303                return Err(serde::de::Error::custom(OutcomeError::InvalidUnknown));
304            }
305            return Self::unknown(
306                wire.case_id,
307                wire.subject,
308                wire.action_id,
309                wire.hypothesis_id,
310                wire.stage,
311                wire.rationale,
312            )
313            .map_err(serde::de::Error::custom);
314        }
315
316        let verifier_rule_id = wire.verifier_rule_id.ok_or_else(|| {
317            serde::de::Error::custom("verified outcome is missing verifier rule id")
318        })?;
319        Self::verified(
320            wire.case_id,
321            wire.subject,
322            wire.action_id,
323            wire.hypothesis_id,
324            verifier_rule_id,
325            wire.stage,
326            wire.status,
327            wire.confidence,
328            wire.rationale,
329            wire.evidence_ids,
330        )
331        .map_err(serde::de::Error::custom)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn subject() -> EntityId {
340        EntityId::new("endpoint:https://example.test").unwrap()
341    }
342
343    #[test]
344    fn verified_outcome_round_trips_with_evidence() {
345        let outcome = Outcome::verified(
346            "case:1",
347            subject(),
348            "sqli.verify",
349            "hypothesis:sqli",
350            "verify.boolean",
351            VerificationStage::Passive,
352            OutcomeStatus::Success,
353            Probability::from_percent(95).unwrap(),
354            "Boolean responses diverged",
355            BTreeSet::from([EvidenceId::parse("evidence:1").unwrap()]),
356        )
357        .unwrap();
358
359        let encoded = serde_json::to_value(&outcome).unwrap();
360        assert_eq!(serde_json::from_value::<Outcome>(encoded).unwrap(), outcome);
361        assert_eq!(
362            outcome.status().hypothesis_state(),
363            Some(HypothesisState::Confirmed)
364        );
365    }
366
367    #[test]
368    fn unknown_outcome_has_canonical_wire_shape() {
369        let outcome = Outcome::unknown(
370            "case:1",
371            subject(),
372            "sqli.verify",
373            "hypothesis:sqli",
374            VerificationStage::Passive,
375            "No rule matched",
376        )
377        .unwrap();
378        let mut encoded = serde_json::to_value(outcome).unwrap();
379        encoded["confidence"] = serde_json::json!(1);
380
381        assert!(serde_json::from_value::<Outcome>(encoded).is_err());
382    }
383
384    #[test]
385    fn confirmed_negative_is_terminal_and_rejects_the_hypothesis() {
386        let outcome = Outcome::verified(
387            "case:negative-control",
388            subject(),
389            "sqli.verify",
390            "hypothesis:sqli",
391            "verify.negative-control",
392            VerificationStage::Active,
393            OutcomeStatus::ConfirmedNegative,
394            Probability::from_percent(97).unwrap(),
395            "The audited negative control disproved the hypothesis",
396            BTreeSet::from([EvidenceId::parse("evidence:negative-control").unwrap()]),
397        )
398        .unwrap();
399
400        assert!(outcome.status().is_terminal());
401        assert_eq!(
402            outcome.status().hypothesis_state(),
403            Some(HypothesisState::Rejected)
404        );
405        let encoded = serde_json::to_value(&outcome).unwrap();
406        assert_eq!(encoded["status"], serde_json::json!("confirmed_negative"));
407        assert_eq!(
408            serde_json::from_value::<Outcome>(encoded.clone()).unwrap(),
409            outcome
410        );
411
412        let mut passive = encoded;
413        passive["stage"] = serde_json::json!("passive");
414        assert!(serde_json::from_value::<Outcome>(passive).is_err());
415    }
416
417    #[test]
418    fn conclusive_outcomes_require_evidence_and_confidence() {
419        assert!(matches!(
420            Outcome::verified(
421                "case:passive-negative",
422                subject(),
423                "sqli.verify",
424                "hypothesis:sqli",
425                "verify.negative-control",
426                VerificationStage::Passive,
427                OutcomeStatus::ConfirmedNegative,
428                Probability::from_percent(95).unwrap(),
429                "A passive observation cannot establish a trusted negative",
430                BTreeSet::from([EvidenceId::parse("evidence:passive").unwrap()]),
431            ),
432            Err(OutcomeError::ConfirmedNegativeRequiresActive)
433        ));
434        assert!(matches!(
435            Outcome::verified(
436                "case:1",
437                subject(),
438                "sqli.verify",
439                "hypothesis:sqli",
440                "verify.boolean",
441                VerificationStage::Active,
442                OutcomeStatus::FalsePositive,
443                Probability::from_percent(80).unwrap(),
444                "Control response matched",
445                BTreeSet::new(),
446            ),
447            Err(OutcomeError::MissingEvidence { .. })
448        ));
449        assert!(matches!(
450            Outcome::verified(
451                "case:1",
452                subject(),
453                "sqli.verify",
454                "hypothesis:sqli",
455                "verify.boolean",
456                VerificationStage::Active,
457                OutcomeStatus::Success,
458                Probability::ZERO,
459                "Timing delta repeated",
460                BTreeSet::from([EvidenceId::parse("evidence:1").unwrap()]),
461            ),
462            Err(OutcomeError::ZeroConfidence { .. })
463        ));
464    }
465}