assay_core/mcp/decision/
consumer_contract.rs1use 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}