1use std::collections::BTreeSet;
7
8use serde::{Deserialize, Deserializer, Serialize};
9use thiserror::Error;
10
11use crate::reasoning::{EntityId, EvidenceId, HypothesisState, Probability};
12
13#[derive(Debug, Clone, PartialEq, Eq, Error)]
15#[non_exhaustive]
16pub enum OutcomeError {
17 #[error("{field} must not be empty")]
19 EmptyValue { field: &'static str },
20
21 #[error("unknown outcomes must be created without a verifier rule or evidence")]
23 InvalidUnknown,
24
25 #[error("confirmed negative outcomes require active verification evidence")]
27 ConfirmedNegativeRequiresActive,
28
29 #[error("outcome {status:?} must reference at least one evidence record")]
31 MissingEvidence { status: OutcomeStatus },
32
33 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum VerificationStage {
51 Passive,
53 Active,
55}
56
57impl VerificationStage {
58 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Passive => "passive",
62 Self::Active => "active",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70#[non_exhaustive]
71pub enum OutcomeStatus {
72 Success,
77 Blocked,
79 Unknown,
81 FalsePositive,
83 NeedsReview,
85 ConfirmedNegative,
91}
92
93impl OutcomeStatus {
94 pub const fn is_terminal(self) -> bool {
96 matches!(
97 self,
98 Self::Success | Self::Blocked | Self::FalsePositive | Self::ConfirmedNegative
99 )
100 }
101
102 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#[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 #[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 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 pub fn case_id(&self) -> &str {
226 &self.case_id
227 }
228
229 pub fn subject(&self) -> &EntityId {
231 &self.subject
232 }
233
234 pub fn action_id(&self) -> &str {
236 &self.action_id
237 }
238
239 pub fn hypothesis_id(&self) -> &str {
244 &self.hypothesis_id
245 }
246
247 pub fn verifier_rule_id(&self) -> Option<&str> {
249 self.verifier_rule_id.as_deref()
250 }
251
252 pub fn stage(&self) -> VerificationStage {
254 self.stage
255 }
256
257 pub fn status(&self) -> OutcomeStatus {
259 self.status
260 }
261
262 pub fn confidence(&self) -> Probability {
264 self.confidence
265 }
266
267 pub fn rationale(&self) -> &str {
269 &self.rationale
270 }
271
272 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}