Skip to main content

assay_core/mcp/decision/
replay_diff.rs

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/// Canonical replay-diff bucket for deterministic policy comparison.
15#[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/// Frozen replay basis used for deterministic diffing.
26#[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
57/// Build replay basis from an emitted decision payload.
58pub 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
171/// Classify replay diff between baseline and candidate basis.
172pub 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}