1use macp_core::error::MacpError;
2use macp_core::session::Session;
3use macp_pb::pb::CommitmentPayload;
4use prost::Message;
5
6pub fn decode_commitment_payload(payload: &[u8]) -> Result<CommitmentPayload, MacpError> {
7 CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
8}
9
10pub fn validate_commitment_payload_for_session(
11 session: &Session,
12 payload: &[u8],
13) -> Result<CommitmentPayload, MacpError> {
14 let commitment = decode_commitment_payload(payload)?;
15
16 if commitment.commitment_id.trim().is_empty()
17 || commitment.action.trim().is_empty()
18 || commitment.authority_scope.trim().is_empty()
19 || commitment.reason.trim().is_empty()
20 {
21 return Err(MacpError::InvalidPayload);
22 }
23
24 if commitment.mode_version != session.mode_version
25 || commitment.configuration_version != session.configuration_version
26 {
27 return Err(MacpError::InvalidPayload);
28 }
29
30 if !session.policy_version.is_empty() && commitment.policy_version != session.policy_version {
31 return Err(MacpError::InvalidPayload);
32 }
33
34 if let Some(ref sup) = commitment.supersedes {
40 if sup.session_id.trim().is_empty() || sup.commitment_hash.trim().is_empty() {
41 return Err(MacpError::InvalidPayload);
42 }
43 }
44
45 validate_outcome_positive(&commitment)?;
47
48 Ok(commitment)
49}
50
51fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
55 let action = commitment.action.as_str();
56 let negative_actions = ["rejected", "failed", "declined"];
57 let positive_actions = ["selected", "accepted", "completed", "approved"];
58
59 let is_negative = negative_actions
60 .iter()
61 .any(|suffix| action.ends_with(suffix));
62 let is_positive = positive_actions
63 .iter()
64 .any(|suffix| action.ends_with(suffix));
65
66 if is_negative && commitment.outcome_positive {
67 return Err(MacpError::InvalidPayload);
68 }
69 if is_positive && !commitment.outcome_positive {
70 return Err(MacpError::InvalidPayload);
71 }
72 Ok(())
73}
74
75pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
76 participants.iter().any(|participant| participant == sender)
77}
78
79pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
84 if let Some(ref policy) = session.policy_definition {
85 let rules: macp_core::policy::rules::CommitmentRules =
86 extract_commitment_rules(&policy.rules);
87 match rules.authority.as_str() {
88 "any_participant" => {
89 if sender == session.initiator_sender
90 || is_declared_participant(&session.participants, sender)
91 {
92 Ok(())
93 } else {
94 Err(MacpError::Forbidden)
95 }
96 }
97 "designated_role" => {
98 if rules.designated_roles.iter().any(|r| r == sender) {
99 Ok(())
100 } else {
101 Err(MacpError::Forbidden)
102 }
103 }
104 _ => {
105 if sender == session.initiator_sender {
107 Ok(())
108 } else {
109 Err(MacpError::Forbidden)
110 }
111 }
112 }
113 } else {
114 if sender == session.initiator_sender {
116 Ok(())
117 } else {
118 Err(MacpError::Forbidden)
119 }
120 }
121}
122
123fn extract_commitment_rules(
126 rules: &serde_json::Value,
127) -> macp_core::policy::rules::CommitmentRules {
128 rules
129 .get("commitment")
130 .and_then(|c| serde_json::from_value(c.clone()).ok())
131 .unwrap_or_default()
132}
133
134pub fn participants_all_accept(
135 participants: &[String],
136 accepts: &std::collections::BTreeMap<String, String>,
137 proposal_id: &str,
138) -> bool {
139 !participants.is_empty()
140 && participants
141 .iter()
142 .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use macp_pb::pb::CommitmentPayload;
149
150 fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
151 CommitmentPayload {
152 commitment_id: "c1".into(),
153 action: action.into(),
154 authority_scope: "scope".into(),
155 reason: "reason".into(),
156 mode_version: "1.0.0".into(),
157 policy_version: String::new(),
158 configuration_version: "cfg-1".into(),
159 outcome_positive,
160 supersedes: None,
161 }
162 }
163
164 fn session_for_commitment() -> Session {
167 use std::collections::{HashMap, HashSet};
168 Session {
169 session_id: "s1".into(),
170 state: macp_core::session::SessionState::Open,
171 ttl_expiry: i64::MAX,
172 ttl_ms: 60_000,
173 started_at_unix_ms: 0,
174 resolution: None,
175 mode: "macp.mode.decision.v1".into(),
176 mode_state: vec![],
177 participants: vec![],
178 seen_message_ids: HashSet::new(),
179 intent: String::new(),
180 mode_version: "1.0.0".into(),
181 configuration_version: "cfg-1".into(),
182 policy_version: String::new(),
183 context_id: String::new(),
184 extensions: HashMap::new(),
185 roots: vec![],
186 initiator_sender: "agent://a".into(),
187 participant_message_counts: HashMap::new(),
188 participant_last_seen: HashMap::new(),
189 policy_definition: None,
190 suspended_at_ms: None,
191 accumulated_suspended_ms: 0,
192 }
193 }
194
195 #[test]
196 fn well_formed_supersedes_is_accepted() {
197 let session = session_for_commitment();
198 let mut c = make_commitment("decision.selected", true);
199 c.supersedes = Some(macp_pb::pb::CommitmentRef {
200 session_id: "prior-session".into(),
201 commitment_hash: "abc123".into(),
202 });
203 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
204 }
205
206 #[test]
207 fn malformed_supersedes_is_rejected() {
208 let session = session_for_commitment();
209 for bad in [("", "abc123"), ("prior-session", ""), (" ", "abc123")] {
210 let mut c = make_commitment("decision.selected", true);
211 c.supersedes = Some(macp_pb::pb::CommitmentRef {
212 session_id: bad.0.into(),
213 commitment_hash: bad.1.into(),
214 });
215 assert!(
216 validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
217 "expected rejection for supersedes {bad:?}"
218 );
219 }
220 }
221
222 #[test]
225 fn decision_selected_positive_ok() {
226 assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
227 }
228
229 #[test]
230 fn decision_selected_negative_rejected() {
231 assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
232 }
233
234 #[test]
235 fn decision_rejected_negative_ok() {
236 assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
237 }
238
239 #[test]
240 fn decision_rejected_positive_rejected() {
241 assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
242 }
243
244 #[test]
245 fn proposal_accepted_positive_ok() {
246 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
247 }
248
249 #[test]
250 fn proposal_accepted_negative_rejected() {
251 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
252 }
253
254 #[test]
255 fn proposal_rejected_negative_ok() {
256 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
257 }
258
259 #[test]
260 fn proposal_rejected_positive_rejected() {
261 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
262 }
263
264 #[test]
265 fn task_completed_positive_ok() {
266 assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
267 }
268
269 #[test]
270 fn task_completed_negative_rejected() {
271 assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
272 }
273
274 #[test]
275 fn task_failed_negative_ok() {
276 assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
277 }
278
279 #[test]
280 fn task_failed_positive_rejected() {
281 assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
282 }
283
284 #[test]
285 fn handoff_accepted_positive_ok() {
286 assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
287 }
288
289 #[test]
290 fn handoff_declined_negative_ok() {
291 assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
292 }
293
294 #[test]
295 fn handoff_declined_positive_rejected() {
296 assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
297 }
298
299 #[test]
300 fn quorum_approved_positive_ok() {
301 assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
302 }
303
304 #[test]
305 fn quorum_rejected_negative_ok() {
306 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
307 }
308
309 #[test]
310 fn quorum_rejected_positive_rejected() {
311 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
312 }
313
314 #[test]
315 fn custom_action_no_known_suffix_any_outcome_ok() {
316 assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
318 assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
319 }
320}