Skip to main content

assay_core/mcp/decision/
deny_convergence.rs

1use super::{reason_codes, Decision, DecisionOrigin, DecisionOutcomeKind, FulfillmentDecisionPath};
2use serde::{Deserialize, Serialize};
3
4pub const DENY_PRECEDENCE_VERSION_V1: &str = "wave40_v1";
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum DenyClassificationSource {
9    OutcomeKind,
10    OriginContext,
11    FulfillmentPath,
12    LegacyDecision,
13    NotDeny,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct DenyConvergenceProjection {
18    pub policy_deny: bool,
19    pub fail_closed_deny: bool,
20    pub enforcement_deny: bool,
21    pub classification_source: DenyClassificationSource,
22    pub legacy_fallback_applied: bool,
23    pub deny_convergence_reason: &'static str,
24}
25
26pub fn project_deny_convergence(
27    decision_outcome_kind: Option<DecisionOutcomeKind>,
28    decision_origin: Option<DecisionOrigin>,
29    fulfillment_decision_path: Option<FulfillmentDecisionPath>,
30    decision: Decision,
31    fail_closed_applied: bool,
32    reason_code: &str,
33) -> DenyConvergenceProjection {
34    if let Some(kind) = decision_outcome_kind {
35        return match kind {
36            DecisionOutcomeKind::PolicyDeny => projection(
37                true,
38                false,
39                false,
40                DenyClassificationSource::OutcomeKind,
41                false,
42                "outcome_policy_deny",
43            ),
44            DecisionOutcomeKind::FailClosedDeny => projection(
45                false,
46                true,
47                false,
48                DenyClassificationSource::OutcomeKind,
49                false,
50                "outcome_fail_closed_deny",
51            ),
52            DecisionOutcomeKind::EnforcementDeny => projection(
53                false,
54                false,
55                true,
56                DenyClassificationSource::OutcomeKind,
57                false,
58                "outcome_enforcement_deny",
59            ),
60            DecisionOutcomeKind::ObligationApplied
61            | DecisionOutcomeKind::ObligationSkipped
62            | DecisionOutcomeKind::ObligationError => not_deny_projection(
63                DenyClassificationSource::OutcomeKind,
64                false,
65                "outcome_not_deny",
66            ),
67        };
68    }
69
70    if let Some(origin_projection) =
71        project_from_origin(decision_origin, decision, fail_closed_applied, reason_code)
72    {
73        return origin_projection;
74    }
75
76    if let Some(path_projection) = project_from_fulfillment_path(fulfillment_decision_path) {
77        return path_projection;
78    }
79
80    project_from_legacy_decision(decision, fail_closed_applied, reason_code)
81}
82
83fn projection(
84    policy_deny: bool,
85    fail_closed_deny: bool,
86    enforcement_deny: bool,
87    classification_source: DenyClassificationSource,
88    legacy_fallback_applied: bool,
89    deny_convergence_reason: &'static str,
90) -> DenyConvergenceProjection {
91    DenyConvergenceProjection {
92        policy_deny,
93        fail_closed_deny,
94        enforcement_deny,
95        classification_source,
96        legacy_fallback_applied,
97        deny_convergence_reason,
98    }
99}
100
101fn not_deny_projection(
102    source: DenyClassificationSource,
103    fallback: bool,
104    reason: &'static str,
105) -> DenyConvergenceProjection {
106    projection(false, false, false, source, fallback, reason)
107}
108
109fn project_from_origin(
110    decision_origin: Option<DecisionOrigin>,
111    decision: Decision,
112    fail_closed_applied: bool,
113    reason_code: &str,
114) -> Option<DenyConvergenceProjection> {
115    let origin = decision_origin?;
116    match origin {
117        DecisionOrigin::FailClosedMatrix => Some(projection(
118            false,
119            true,
120            false,
121            DenyClassificationSource::OriginContext,
122            true,
123            "origin_fail_closed_matrix",
124        )),
125        DecisionOrigin::RuntimeEnforcement => Some(projection(
126            false,
127            false,
128            true,
129            DenyClassificationSource::OriginContext,
130            true,
131            "origin_runtime_enforcement",
132        )),
133        DecisionOrigin::PolicyEngine => match decision {
134            Decision::Deny => Some(projection(
135                true,
136                false,
137                false,
138                DenyClassificationSource::OriginContext,
139                true,
140                "origin_policy_engine_deny",
141            )),
142            Decision::Error => Some(projection(
143                false,
144                false,
145                true,
146                DenyClassificationSource::OriginContext,
147                true,
148                "origin_policy_engine_error",
149            )),
150            Decision::Allow => Some(not_deny_projection(
151                DenyClassificationSource::OriginContext,
152                true,
153                "origin_policy_engine_allow",
154            )),
155        },
156        DecisionOrigin::ObligationExecutor => Some(project_from_legacy_decision(
157            decision,
158            fail_closed_applied,
159            reason_code,
160        )),
161    }
162}
163
164fn project_from_fulfillment_path(
165    fulfillment_decision_path: Option<FulfillmentDecisionPath>,
166) -> Option<DenyConvergenceProjection> {
167    let path = fulfillment_decision_path?;
168    match path {
169        FulfillmentDecisionPath::PolicyDeny => Some(projection(
170            true,
171            false,
172            false,
173            DenyClassificationSource::FulfillmentPath,
174            true,
175            "fulfillment_policy_deny",
176        )),
177        FulfillmentDecisionPath::FailClosedDeny => Some(projection(
178            false,
179            true,
180            false,
181            DenyClassificationSource::FulfillmentPath,
182            true,
183            "fulfillment_fail_closed_deny",
184        )),
185        FulfillmentDecisionPath::DecisionError => Some(projection(
186            false,
187            false,
188            true,
189            DenyClassificationSource::FulfillmentPath,
190            true,
191            "fulfillment_decision_error",
192        )),
193        FulfillmentDecisionPath::PolicyAllow => Some(not_deny_projection(
194            DenyClassificationSource::FulfillmentPath,
195            true,
196            "fulfillment_policy_allow",
197        )),
198    }
199}
200
201fn project_from_legacy_decision(
202    decision: Decision,
203    fail_closed_applied: bool,
204    reason_code: &str,
205) -> DenyConvergenceProjection {
206    match decision {
207        Decision::Deny => {
208            if fail_closed_applied {
209                projection(
210                    false,
211                    true,
212                    false,
213                    DenyClassificationSource::LegacyDecision,
214                    true,
215                    "legacy_fail_closed_deny",
216                )
217            } else if is_enforcement_deny_reason(reason_code) {
218                projection(
219                    false,
220                    false,
221                    true,
222                    DenyClassificationSource::LegacyDecision,
223                    true,
224                    "legacy_enforcement_deny",
225                )
226            } else {
227                projection(
228                    true,
229                    false,
230                    false,
231                    DenyClassificationSource::LegacyDecision,
232                    true,
233                    "legacy_policy_deny",
234                )
235            }
236        }
237        Decision::Error => projection(
238            false,
239            false,
240            true,
241            DenyClassificationSource::LegacyDecision,
242            true,
243            "legacy_decision_error",
244        ),
245        Decision::Allow => not_deny_projection(
246            DenyClassificationSource::NotDeny,
247            true,
248            "legacy_decision_allow",
249        ),
250    }
251}
252
253fn is_enforcement_deny_reason(reason_code: &str) -> bool {
254    reason_code.starts_with("M_")
255        || matches!(
256            reason_code,
257            reason_codes::P_APPROVAL_REQUIRED
258                | reason_codes::P_RESTRICT_SCOPE
259                | reason_codes::P_REDACT_ARGS
260                | reason_codes::P_MANDATE_REQUIRED
261        )
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn prefers_outcome_kind_for_policy_deny() {
270        let projection = project_deny_convergence(
271            Some(DecisionOutcomeKind::PolicyDeny),
272            Some(DecisionOrigin::PolicyEngine),
273            Some(FulfillmentDecisionPath::PolicyDeny),
274            Decision::Deny,
275            false,
276            reason_codes::P_POLICY_DENY,
277        );
278        assert!(projection.policy_deny);
279        assert!(!projection.fail_closed_deny);
280        assert!(!projection.enforcement_deny);
281        assert_eq!(
282            projection.classification_source,
283            DenyClassificationSource::OutcomeKind
284        );
285        assert!(!projection.legacy_fallback_applied);
286        assert_eq!(projection.deny_convergence_reason, "outcome_policy_deny");
287    }
288
289    #[test]
290    fn falls_back_to_origin_context() {
291        let projection = project_deny_convergence(
292            None,
293            Some(DecisionOrigin::FailClosedMatrix),
294            Some(FulfillmentDecisionPath::PolicyDeny),
295            Decision::Deny,
296            true,
297            reason_codes::S_DB_ERROR,
298        );
299        assert!(!projection.policy_deny);
300        assert!(projection.fail_closed_deny);
301        assert!(!projection.enforcement_deny);
302        assert_eq!(
303            projection.classification_source,
304            DenyClassificationSource::OriginContext
305        );
306        assert!(projection.legacy_fallback_applied);
307        assert_eq!(
308            projection.deny_convergence_reason,
309            "origin_fail_closed_matrix"
310        );
311    }
312
313    #[test]
314    fn falls_back_to_fulfillment_path() {
315        let projection = project_deny_convergence(
316            None,
317            None,
318            Some(FulfillmentDecisionPath::DecisionError),
319            Decision::Error,
320            false,
321            reason_codes::S_INTERNAL_ERROR,
322        );
323        assert!(!projection.policy_deny);
324        assert!(!projection.fail_closed_deny);
325        assert!(projection.enforcement_deny);
326        assert_eq!(
327            projection.classification_source,
328            DenyClassificationSource::FulfillmentPath
329        );
330        assert!(projection.legacy_fallback_applied);
331        assert_eq!(
332            projection.deny_convergence_reason,
333            "fulfillment_decision_error"
334        );
335    }
336
337    #[test]
338    fn falls_back_to_legacy_decision() {
339        let projection = project_deny_convergence(
340            None,
341            None,
342            None,
343            Decision::Deny,
344            false,
345            reason_codes::P_POLICY_DENY,
346        );
347        assert!(projection.policy_deny);
348        assert!(!projection.fail_closed_deny);
349        assert!(!projection.enforcement_deny);
350        assert_eq!(
351            projection.classification_source,
352            DenyClassificationSource::LegacyDecision
353        );
354        assert!(projection.legacy_fallback_applied);
355        assert_eq!(projection.deny_convergence_reason, "legacy_policy_deny");
356    }
357}