Skip to main content

assay_core/mcp/decision/
replay_compat.rs

1use 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}