assay_core/mcp/decision/
replay_compat.rs1use super::{
2 Decision, DecisionOrigin, DecisionOutcomeKind, FulfillmentDecisionPath, OutcomeCompatState,
3};
4use serde::{Deserialize, Serialize};
5
6pub const DECISION_BASIS_VERSION_V1: &str = "wave39_v1";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ReplayClassificationSource {
11 ConvergedOutcome,
12 FulfillmentPath,
13 LegacyFallback,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ReplayCompatProjection {
18 pub compat_fallback_applied: bool,
19 pub classification_source: ReplayClassificationSource,
20 pub replay_diff_reason: &'static str,
21 pub legacy_shape_detected: bool,
22}
23
24pub fn project_replay_compat(
25 decision_outcome_kind: Option<DecisionOutcomeKind>,
26 decision_origin: Option<DecisionOrigin>,
27 outcome_compat_state: Option<OutcomeCompatState>,
28 fulfillment_decision_path: Option<FulfillmentDecisionPath>,
29 decision: Decision,
30) -> ReplayCompatProjection {
31 let legacy_shape_detected = decision_outcome_kind.is_none()
32 || decision_origin.is_none()
33 || outcome_compat_state.is_none()
34 || fulfillment_decision_path.is_none();
35
36 let (classification_source, replay_diff_reason) = if let Some(kind) = decision_outcome_kind {
37 (
38 ReplayClassificationSource::ConvergedOutcome,
39 reason_from_outcome_kind(kind),
40 )
41 } else if let Some(path) = fulfillment_decision_path {
42 (
43 ReplayClassificationSource::FulfillmentPath,
44 reason_from_fulfillment_path(path),
45 )
46 } else {
47 (
48 ReplayClassificationSource::LegacyFallback,
49 reason_from_legacy_decision(decision),
50 )
51 };
52
53 ReplayCompatProjection {
54 compat_fallback_applied: classification_source
55 != ReplayClassificationSource::ConvergedOutcome,
56 classification_source,
57 replay_diff_reason,
58 legacy_shape_detected,
59 }
60}
61
62fn reason_from_outcome_kind(kind: DecisionOutcomeKind) -> &'static str {
63 match kind {
64 DecisionOutcomeKind::PolicyDeny => "converged_policy_deny",
65 DecisionOutcomeKind::FailClosedDeny => "converged_fail_closed_deny",
66 DecisionOutcomeKind::ObligationApplied => "converged_obligation_applied",
67 DecisionOutcomeKind::ObligationSkipped => "converged_obligation_skipped",
68 DecisionOutcomeKind::ObligationError => "converged_obligation_error",
69 DecisionOutcomeKind::EnforcementDeny => "converged_enforcement_deny",
70 }
71}
72
73fn reason_from_fulfillment_path(path: FulfillmentDecisionPath) -> &'static str {
74 match path {
75 FulfillmentDecisionPath::PolicyAllow => "fulfillment_policy_allow",
76 FulfillmentDecisionPath::PolicyDeny => "fulfillment_policy_deny",
77 FulfillmentDecisionPath::FailClosedDeny => "fulfillment_fail_closed_deny",
78 FulfillmentDecisionPath::DecisionError => "fulfillment_decision_error",
79 }
80}
81
82fn reason_from_legacy_decision(decision: Decision) -> &'static str {
83 match decision {
84 Decision::Allow => "legacy_decision_allow",
85 Decision::Deny => "legacy_decision_deny",
86 Decision::Error => "legacy_decision_error",
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn precedence_prefers_converged_markers() {
96 let projection = project_replay_compat(
97 Some(DecisionOutcomeKind::ObligationApplied),
98 Some(DecisionOrigin::ObligationExecutor),
99 Some(OutcomeCompatState::LegacyFieldsPreserved),
100 Some(FulfillmentDecisionPath::PolicyAllow),
101 Decision::Allow,
102 );
103
104 assert_eq!(
105 projection.classification_source,
106 ReplayClassificationSource::ConvergedOutcome
107 );
108 assert_eq!(
109 projection.replay_diff_reason,
110 "converged_obligation_applied"
111 );
112 assert!(!projection.compat_fallback_applied);
113 assert!(!projection.legacy_shape_detected);
114 }
115
116 #[test]
117 fn precedence_falls_back_to_fulfillment_path() {
118 let projection = project_replay_compat(
119 None,
120 None,
121 None,
122 Some(FulfillmentDecisionPath::PolicyAllow),
123 Decision::Allow,
124 );
125
126 assert_eq!(
127 projection.classification_source,
128 ReplayClassificationSource::FulfillmentPath
129 );
130 assert_eq!(projection.replay_diff_reason, "fulfillment_policy_allow");
131 assert!(projection.compat_fallback_applied);
132 assert!(projection.legacy_shape_detected);
133 }
134
135 #[test]
136 fn precedence_falls_back_to_legacy_decision() {
137 let projection = project_replay_compat(None, None, None, None, Decision::Deny);
138
139 assert_eq!(
140 projection.classification_source,
141 ReplayClassificationSource::LegacyFallback
142 );
143 assert_eq!(projection.replay_diff_reason, "legacy_decision_deny");
144 assert!(projection.compat_fallback_applied);
145 assert!(projection.legacy_shape_detected);
146 }
147}