Skip to main content

assay_core/mcp/decision/
consumer_contract.rs

1use super::{
2    DecisionOrigin, DecisionOutcomeKind, FulfillmentDecisionPath, ReplayClassificationSource,
3};
4use serde::{Deserialize, Serialize};
5
6pub const DECISION_CONSUMER_CONTRACT_VERSION_V1: &str = "wave41_v1";
7
8const REQUIRED_CONSUMER_FIELDS_V1: &[&str] = &[
9    "decision",
10    "reason_code",
11    "decision_outcome_kind",
12    "decision_origin",
13    "fulfillment_decision_path",
14    "decision_basis_version",
15];
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ConsumerReadPath {
20    ConvergedDecision,
21    CompatibilityMarkers,
22    LegacyDecision,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ConsumerPayloadState {
28    Converged,
29    CompatibilityFallback,
30    LegacyBase,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ConsumerContractProjection {
35    pub read_path: ConsumerReadPath,
36    pub fallback_applied: bool,
37    pub payload_state: ConsumerPayloadState,
38    pub required_consumer_fields: Vec<String>,
39}
40
41pub fn project_consumer_contract(
42    decision_outcome_kind: Option<DecisionOutcomeKind>,
43    decision_origin: Option<DecisionOrigin>,
44    fulfillment_decision_path: Option<FulfillmentDecisionPath>,
45    decision_basis_version: Option<&str>,
46    compat_fallback_applied: Option<bool>,
47    classification_source: Option<ReplayClassificationSource>,
48    legacy_shape_detected: Option<bool>,
49) -> ConsumerContractProjection {
50    let converged_present = decision_outcome_kind.is_some()
51        && decision_origin.is_some()
52        && fulfillment_decision_path.is_some();
53    let compatibility_present = decision_basis_version.is_some()
54        || compat_fallback_applied.is_some()
55        || classification_source.is_some()
56        || legacy_shape_detected.is_some();
57
58    let read_path = if converged_present {
59        ConsumerReadPath::ConvergedDecision
60    } else if compatibility_present {
61        ConsumerReadPath::CompatibilityMarkers
62    } else {
63        ConsumerReadPath::LegacyDecision
64    };
65
66    let fallback_applied = read_path != ConsumerReadPath::ConvergedDecision;
67
68    let payload_state = match read_path {
69        ConsumerReadPath::ConvergedDecision => {
70            if compat_fallback_applied.unwrap_or(false) || legacy_shape_detected.unwrap_or(false) {
71                ConsumerPayloadState::CompatibilityFallback
72            } else {
73                ConsumerPayloadState::Converged
74            }
75        }
76        ConsumerReadPath::CompatibilityMarkers => ConsumerPayloadState::CompatibilityFallback,
77        ConsumerReadPath::LegacyDecision => ConsumerPayloadState::LegacyBase,
78    };
79
80    ConsumerContractProjection {
81        read_path,
82        fallback_applied,
83        payload_state,
84        required_consumer_fields: required_consumer_fields_v1(),
85    }
86}
87
88pub fn required_consumer_fields_v1() -> Vec<String> {
89    REQUIRED_CONSUMER_FIELDS_V1
90        .iter()
91        .map(|field| (*field).to_string())
92        .collect()
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn prefers_converged_decision_fields() {
101        let projection = project_consumer_contract(
102            Some(DecisionOutcomeKind::ObligationApplied),
103            Some(DecisionOrigin::ObligationExecutor),
104            Some(FulfillmentDecisionPath::PolicyAllow),
105            Some("wave39_v1"),
106            Some(false),
107            Some(ReplayClassificationSource::ConvergedOutcome),
108            Some(false),
109        );
110
111        assert_eq!(projection.read_path, ConsumerReadPath::ConvergedDecision);
112        assert!(!projection.fallback_applied);
113        assert_eq!(projection.payload_state, ConsumerPayloadState::Converged);
114        assert_eq!(
115            projection.required_consumer_fields,
116            required_consumer_fields_v1()
117        );
118    }
119
120    #[test]
121    fn falls_back_to_compatibility_markers() {
122        let projection = project_consumer_contract(
123            None,
124            None,
125            None,
126            Some("wave39_v1"),
127            Some(true),
128            Some(ReplayClassificationSource::FulfillmentPath),
129            Some(true),
130        );
131
132        assert_eq!(projection.read_path, ConsumerReadPath::CompatibilityMarkers);
133        assert!(projection.fallback_applied);
134        assert_eq!(
135            projection.payload_state,
136            ConsumerPayloadState::CompatibilityFallback
137        );
138    }
139
140    #[test]
141    fn falls_back_to_legacy_decision_when_no_markers_exist() {
142        let projection = project_consumer_contract(None, None, None, None, None, None, None);
143
144        assert_eq!(projection.read_path, ConsumerReadPath::LegacyDecision);
145        assert!(projection.fallback_applied);
146        assert_eq!(projection.payload_state, ConsumerPayloadState::LegacyBase);
147    }
148
149    #[test]
150    fn partial_markers_still_count_as_consumer_fallback() {
151        let projection = project_consumer_contract(
152            Some(DecisionOutcomeKind::ObligationApplied),
153            None,
154            None,
155            Some("wave39_v1"),
156            Some(false),
157            Some(ReplayClassificationSource::ConvergedOutcome),
158            Some(false),
159        );
160
161        assert_eq!(projection.read_path, ConsumerReadPath::CompatibilityMarkers);
162        assert!(projection.fallback_applied);
163        assert_eq!(
164            projection.payload_state,
165            ConsumerPayloadState::CompatibilityFallback
166        );
167    }
168}