1use super::{
2 consumer_contract::{
3 project_consumer_contract, required_consumer_fields_v1, ConsumerPayloadState,
4 ConsumerReadPath, DECISION_CONSUMER_CONTRACT_VERSION_V1,
5 },
6 deny_convergence::{project_deny_convergence, DENY_PRECEDENCE_VERSION_V1},
7 replay_compat::{project_replay_compat, DECISION_BASIS_VERSION_V1},
8 Decision, DecisionData, DecisionOrigin, DecisionOutcomeKind, DenyClassificationSource,
9 FulfillmentDecisionPath, OutcomeCompatState, ReplayClassificationSource,
10};
11use crate::mcp::policy::TypedPolicyDecision;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ReplayDiffBucket {
18 Unchanged,
19 Stricter,
20 Looser,
21 Reclassified,
22 EvidenceOnly,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ReplayDiffBasis {
28 pub decision_outcome_kind: Option<DecisionOutcomeKind>,
29 pub decision_origin: Option<DecisionOrigin>,
30 pub outcome_compat_state: Option<OutcomeCompatState>,
31 pub fulfillment_decision_path: Option<FulfillmentDecisionPath>,
32 pub decision_basis_version: String,
33 pub compat_fallback_applied: bool,
34 pub classification_source: ReplayClassificationSource,
35 pub replay_diff_reason: String,
36 pub legacy_shape_detected: bool,
37 pub decision_consumer_contract_version: String,
38 pub consumer_read_path: ConsumerReadPath,
39 pub consumer_fallback_applied: bool,
40 pub consumer_payload_state: ConsumerPayloadState,
41 pub required_consumer_fields: Vec<String>,
42 pub policy_deny: bool,
43 pub fail_closed_deny: bool,
44 pub enforcement_deny: bool,
45 pub deny_precedence_version: String,
46 pub deny_classification_source: DenyClassificationSource,
47 pub deny_legacy_fallback_applied: bool,
48 pub deny_convergence_reason: String,
49 pub reason_code: String,
50 pub typed_decision: Option<TypedPolicyDecision>,
51 pub policy_version: Option<String>,
52 pub policy_digest: Option<String>,
53 pub decision: Decision,
54 pub fail_closed_applied: bool,
55}
56
57pub fn basis_from_decision_data(data: &DecisionData) -> ReplayDiffBasis {
59 let fail_closed_applied = data
60 .fail_closed
61 .as_ref()
62 .map(|ctx| ctx.fail_closed_applied)
63 .unwrap_or(false);
64
65 let replay_projection = project_replay_compat(
66 data.decision_outcome_kind,
67 data.decision_origin,
68 data.outcome_compat_state,
69 data.fulfillment_decision_path,
70 data.decision,
71 );
72 let consumer_projection = project_consumer_contract(
73 data.decision_outcome_kind,
74 data.decision_origin,
75 data.fulfillment_decision_path,
76 data.decision_basis_version
77 .as_deref()
78 .or(Some(DECISION_BASIS_VERSION_V1)),
79 Some(
80 data.compat_fallback_applied
81 .unwrap_or(replay_projection.compat_fallback_applied),
82 ),
83 Some(
84 data.classification_source
85 .unwrap_or(replay_projection.classification_source),
86 ),
87 Some(
88 data.legacy_shape_detected
89 .unwrap_or(replay_projection.legacy_shape_detected),
90 ),
91 );
92 let deny_projection = project_deny_convergence(
93 data.decision_outcome_kind,
94 data.decision_origin,
95 data.fulfillment_decision_path,
96 data.decision,
97 fail_closed_applied,
98 data.reason_code.as_str(),
99 );
100
101 ReplayDiffBasis {
102 decision_outcome_kind: data.decision_outcome_kind,
103 decision_origin: data.decision_origin,
104 outcome_compat_state: data.outcome_compat_state,
105 fulfillment_decision_path: data.fulfillment_decision_path,
106 decision_basis_version: data
107 .decision_basis_version
108 .clone()
109 .unwrap_or_else(|| DECISION_BASIS_VERSION_V1.to_string()),
110 compat_fallback_applied: data
111 .compat_fallback_applied
112 .unwrap_or(replay_projection.compat_fallback_applied),
113 classification_source: data
114 .classification_source
115 .unwrap_or(replay_projection.classification_source),
116 replay_diff_reason: data
117 .replay_diff_reason
118 .clone()
119 .unwrap_or_else(|| replay_projection.replay_diff_reason.to_string()),
120 legacy_shape_detected: data
121 .legacy_shape_detected
122 .unwrap_or(replay_projection.legacy_shape_detected),
123 decision_consumer_contract_version: data
124 .decision_consumer_contract_version
125 .clone()
126 .unwrap_or_else(|| DECISION_CONSUMER_CONTRACT_VERSION_V1.to_string()),
127 consumer_read_path: data
128 .consumer_read_path
129 .unwrap_or(consumer_projection.read_path),
130 consumer_fallback_applied: data
131 .consumer_fallback_applied
132 .unwrap_or(consumer_projection.fallback_applied),
133 consumer_payload_state: data
134 .consumer_payload_state
135 .unwrap_or(consumer_projection.payload_state),
136 required_consumer_fields: if data.required_consumer_fields.is_empty() {
137 required_consumer_fields_v1()
138 } else {
139 data.required_consumer_fields.clone()
140 },
141 policy_deny: data.policy_deny.unwrap_or(deny_projection.policy_deny),
142 fail_closed_deny: data
143 .fail_closed_deny
144 .unwrap_or(deny_projection.fail_closed_deny),
145 enforcement_deny: data
146 .enforcement_deny
147 .unwrap_or(deny_projection.enforcement_deny),
148 deny_precedence_version: data
149 .deny_precedence_version
150 .clone()
151 .unwrap_or_else(|| DENY_PRECEDENCE_VERSION_V1.to_string()),
152 deny_classification_source: data
153 .deny_classification_source
154 .unwrap_or(deny_projection.classification_source),
155 deny_legacy_fallback_applied: data
156 .deny_legacy_fallback_applied
157 .unwrap_or(deny_projection.legacy_fallback_applied),
158 deny_convergence_reason: data
159 .deny_convergence_reason
160 .clone()
161 .unwrap_or_else(|| deny_projection.deny_convergence_reason.to_string()),
162 reason_code: data.reason_code.clone(),
163 typed_decision: data.typed_decision,
164 policy_version: data.policy_version.clone(),
165 policy_digest: data.policy_digest.clone(),
166 decision: data.decision,
167 fail_closed_applied,
168 }
169}
170
171pub fn classify_replay_diff(
173 baseline: &ReplayDiffBasis,
174 candidate: &ReplayDiffBasis,
175) -> ReplayDiffBucket {
176 if baseline == candidate {
177 return ReplayDiffBucket::Unchanged;
178 }
179
180 if same_effective_decision_class(baseline, candidate) {
181 return ReplayDiffBucket::EvidenceOnly;
182 }
183
184 let baseline_rank = restrictiveness_rank(baseline);
185 let candidate_rank = restrictiveness_rank(candidate);
186
187 if candidate_rank > baseline_rank {
188 return ReplayDiffBucket::Stricter;
189 }
190
191 if candidate_rank < baseline_rank {
192 return ReplayDiffBucket::Looser;
193 }
194
195 ReplayDiffBucket::Reclassified
196}
197
198fn same_effective_decision_class(baseline: &ReplayDiffBasis, candidate: &ReplayDiffBasis) -> bool {
199 baseline.decision_outcome_kind == candidate.decision_outcome_kind
200 && baseline.decision_origin == candidate.decision_origin
201 && baseline.outcome_compat_state == candidate.outcome_compat_state
202 && baseline.fulfillment_decision_path == candidate.fulfillment_decision_path
203 && baseline.decision_basis_version == candidate.decision_basis_version
204 && baseline.compat_fallback_applied == candidate.compat_fallback_applied
205 && baseline.classification_source == candidate.classification_source
206 && baseline.replay_diff_reason == candidate.replay_diff_reason
207 && baseline.legacy_shape_detected == candidate.legacy_shape_detected
208 && baseline.decision_consumer_contract_version
209 == candidate.decision_consumer_contract_version
210 && baseline.consumer_read_path == candidate.consumer_read_path
211 && baseline.consumer_fallback_applied == candidate.consumer_fallback_applied
212 && baseline.consumer_payload_state == candidate.consumer_payload_state
213 && baseline.required_consumer_fields == candidate.required_consumer_fields
214 && baseline.policy_deny == candidate.policy_deny
215 && baseline.fail_closed_deny == candidate.fail_closed_deny
216 && baseline.enforcement_deny == candidate.enforcement_deny
217 && baseline.deny_precedence_version == candidate.deny_precedence_version
218 && baseline.deny_classification_source == candidate.deny_classification_source
219 && baseline.deny_legacy_fallback_applied == candidate.deny_legacy_fallback_applied
220 && baseline.deny_convergence_reason == candidate.deny_convergence_reason
221 && baseline.reason_code == candidate.reason_code
222 && baseline.typed_decision == candidate.typed_decision
223 && baseline.decision == candidate.decision
224 && baseline.fail_closed_applied == candidate.fail_closed_applied
225}
226
227fn restrictiveness_rank(basis: &ReplayDiffBasis) -> u8 {
228 match basis.decision_outcome_kind {
229 Some(DecisionOutcomeKind::PolicyDeny)
230 | Some(DecisionOutcomeKind::FailClosedDeny)
231 | Some(DecisionOutcomeKind::EnforcementDeny) => 2,
232 Some(DecisionOutcomeKind::ObligationApplied)
233 | Some(DecisionOutcomeKind::ObligationSkipped)
234 | Some(DecisionOutcomeKind::ObligationError) => 1,
235 None => match basis.fulfillment_decision_path {
236 Some(FulfillmentDecisionPath::PolicyDeny)
237 | Some(FulfillmentDecisionPath::FailClosedDeny)
238 | Some(FulfillmentDecisionPath::DecisionError) => 2,
239 Some(FulfillmentDecisionPath::PolicyAllow) => 1,
240 None => match basis.decision {
241 Decision::Deny | Decision::Error => 2,
242 Decision::Allow => 1,
243 },
244 },
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn make_basis(kind: Option<DecisionOutcomeKind>, reason: &str) -> ReplayDiffBasis {
253 ReplayDiffBasis {
254 decision_outcome_kind: kind,
255 decision_origin: Some(DecisionOrigin::PolicyEngine),
256 outcome_compat_state: Some(OutcomeCompatState::LegacyFieldsPreserved),
257 fulfillment_decision_path: Some(FulfillmentDecisionPath::PolicyAllow),
258 decision_basis_version: DECISION_BASIS_VERSION_V1.to_string(),
259 compat_fallback_applied: false,
260 classification_source: ReplayClassificationSource::ConvergedOutcome,
261 replay_diff_reason: "converged_obligation_applied".to_string(),
262 legacy_shape_detected: false,
263 decision_consumer_contract_version: DECISION_CONSUMER_CONTRACT_VERSION_V1.to_string(),
264 consumer_read_path: ConsumerReadPath::ConvergedDecision,
265 consumer_fallback_applied: false,
266 consumer_payload_state: ConsumerPayloadState::Converged,
267 required_consumer_fields: required_consumer_fields_v1(),
268 policy_deny: false,
269 fail_closed_deny: false,
270 enforcement_deny: false,
271 deny_precedence_version: DENY_PRECEDENCE_VERSION_V1.to_string(),
272 deny_classification_source: DenyClassificationSource::OutcomeKind,
273 deny_legacy_fallback_applied: false,
274 deny_convergence_reason: "outcome_not_deny".to_string(),
275 reason_code: reason.to_string(),
276 typed_decision: Some(TypedPolicyDecision::AllowWithObligations),
277 policy_version: Some("v1".to_string()),
278 policy_digest: Some("sha1".to_string()),
279 decision: Decision::Allow,
280 fail_closed_applied: false,
281 }
282 }
283
284 #[test]
285 fn classifies_unchanged() {
286 let a = make_basis(
287 Some(DecisionOutcomeKind::ObligationApplied),
288 "P_POLICY_ALLOW",
289 );
290 assert_eq!(classify_replay_diff(&a, &a), ReplayDiffBucket::Unchanged);
291 }
292
293 #[test]
294 fn classifies_evidence_only() {
295 let baseline = make_basis(
296 Some(DecisionOutcomeKind::ObligationApplied),
297 "P_POLICY_ALLOW",
298 );
299 let mut candidate = baseline.clone();
300 candidate.policy_version = Some("v2".to_string());
301 candidate.policy_digest = Some("sha2".to_string());
302 assert_eq!(
303 classify_replay_diff(&baseline, &candidate),
304 ReplayDiffBucket::EvidenceOnly
305 );
306 }
307
308 #[test]
309 fn classifies_stricter_and_looser() {
310 let allow = make_basis(
311 Some(DecisionOutcomeKind::ObligationApplied),
312 "P_POLICY_ALLOW",
313 );
314 let deny = make_basis(Some(DecisionOutcomeKind::PolicyDeny), "P_POLICY_DENY");
315 assert_eq!(
316 classify_replay_diff(&allow, &deny),
317 ReplayDiffBucket::Stricter
318 );
319 assert_eq!(
320 classify_replay_diff(&deny, &allow),
321 ReplayDiffBucket::Looser
322 );
323 }
324
325 #[test]
326 fn classifies_reclassified() {
327 let mut baseline = make_basis(Some(DecisionOutcomeKind::PolicyDeny), "P_POLICY_DENY");
328 baseline.fulfillment_decision_path = Some(FulfillmentDecisionPath::PolicyDeny);
329 baseline.decision = Decision::Deny;
330
331 let mut candidate = baseline.clone();
332 candidate.decision_outcome_kind = Some(DecisionOutcomeKind::FailClosedDeny);
333 candidate.decision_origin = Some(DecisionOrigin::FailClosedMatrix);
334 candidate.fulfillment_decision_path = Some(FulfillmentDecisionPath::FailClosedDeny);
335 candidate.fail_closed_applied = true;
336
337 assert_eq!(
338 classify_replay_diff(&baseline, &candidate),
339 ReplayDiffBucket::Reclassified
340 );
341 }
342
343 #[test]
344 fn classifies_legacy_events_with_decision_fallback() {
345 let baseline = ReplayDiffBasis {
346 decision_outcome_kind: None,
347 decision_origin: None,
348 outcome_compat_state: None,
349 fulfillment_decision_path: None,
350 decision_basis_version: DECISION_BASIS_VERSION_V1.to_string(),
351 compat_fallback_applied: true,
352 classification_source: ReplayClassificationSource::LegacyFallback,
353 replay_diff_reason: "legacy_decision_allow".to_string(),
354 legacy_shape_detected: true,
355 decision_consumer_contract_version: DECISION_CONSUMER_CONTRACT_VERSION_V1.to_string(),
356 consumer_read_path: ConsumerReadPath::LegacyDecision,
357 consumer_fallback_applied: true,
358 consumer_payload_state: ConsumerPayloadState::LegacyBase,
359 required_consumer_fields: required_consumer_fields_v1(),
360 policy_deny: false,
361 fail_closed_deny: false,
362 enforcement_deny: false,
363 deny_precedence_version: DENY_PRECEDENCE_VERSION_V1.to_string(),
364 deny_classification_source: DenyClassificationSource::NotDeny,
365 deny_legacy_fallback_applied: true,
366 deny_convergence_reason: "legacy_decision_allow".to_string(),
367 reason_code: "P_POLICY_ALLOW".to_string(),
368 typed_decision: None,
369 policy_version: None,
370 policy_digest: None,
371 decision: Decision::Allow,
372 fail_closed_applied: false,
373 };
374 let candidate = ReplayDiffBasis {
375 decision: Decision::Deny,
376 reason_code: "P_POLICY_DENY".to_string(),
377 ..baseline.clone()
378 };
379
380 assert_eq!(
381 classify_replay_diff(&baseline, &candidate),
382 ReplayDiffBucket::Stricter
383 );
384 assert_eq!(
385 classify_replay_diff(&candidate, &baseline),
386 ReplayDiffBucket::Looser
387 );
388 }
389}