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 !commitment.policy_version.is_empty()
38 && !session.policy_version.is_empty()
39 && commitment.policy_version != session.policy_version
40 {
41 return Err(MacpError::InvalidPayload);
42 }
43
44 if let Some(ref sup) = commitment.supersedes {
50 if sup.session_id.trim().is_empty() || sup.commitment_hash.trim().is_empty() {
51 return Err(MacpError::InvalidPayload);
52 }
53 }
54
55 validate_outcome_positive(&commitment)?;
57
58 Ok(commitment)
59}
60
61fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
65 let action = commitment.action.as_str();
66 let negative_actions = ["rejected", "failed", "declined"];
67 let positive_actions = ["selected", "accepted", "completed", "approved"];
68
69 let is_negative = negative_actions
70 .iter()
71 .any(|suffix| action.ends_with(suffix));
72 let is_positive = positive_actions
73 .iter()
74 .any(|suffix| action.ends_with(suffix));
75
76 if is_negative && commitment.outcome_positive {
77 return Err(MacpError::InvalidPayload);
78 }
79 if is_positive && !commitment.outcome_positive {
80 return Err(MacpError::InvalidPayload);
81 }
82 Ok(())
83}
84
85pub fn enforce_commitment_policy(
89 session: &Session,
90 mode: macp_core::policy::CommitmentMode<'_>,
91 outcome_positive: bool,
92 evaluator: &dyn macp_core::policy::PolicyEvaluator,
93) -> Result<(), MacpError> {
94 let Some(ref policy) = session.policy_definition else {
95 return Ok(());
96 };
97 let decision = evaluator.evaluate_commitment(&macp_core::policy::CommitmentContext {
98 policy,
99 participants: &session.participants,
100 outcome_positive,
101 mode,
102 });
103 match decision {
104 macp_core::policy::PolicyDecision::Allow { .. } => Ok(()),
105 macp_core::policy::PolicyDecision::Deny { reasons } => {
106 tracing::warn!(
107 session_id = %session.session_id,
108 policy_id = %policy.policy_id,
109 reasons = ?reasons,
110 "policy denied commitment"
111 );
112 Err(MacpError::PolicyDenied { reasons })
113 }
114 other => {
115 tracing::warn!(
116 session_id = %session.session_id,
117 policy_id = %policy.policy_id,
118 decision = ?other,
119 "unrecognized policy decision treated as denial"
120 );
121 Err(MacpError::PolicyDenied {
122 reasons: vec!["unrecognized policy decision".into()],
123 })
124 }
125 }
126}
127
128pub fn encode_mode_state<T: serde::Serialize>(state: &T) -> Vec<u8> {
132 serde_json::to_vec(state).expect("mode state is always serializable")
133}
134
135pub fn decode_mode_state<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MacpError> {
136 serde_json::from_slice(bytes).map_err(|_| MacpError::InvalidModeState)
137}
138
139pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
140 participants.iter().any(|participant| participant == sender)
141}
142
143pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
148 if let Some(ref policy) = session.policy_definition {
149 let rules: macp_core::policy::rules::CommitmentRules =
150 extract_commitment_rules(&policy.rules);
151 match rules.authority.as_str() {
152 "any_participant" => {
153 if sender == session.initiator_sender
154 || is_declared_participant(&session.participants, sender)
155 {
156 Ok(())
157 } else {
158 Err(MacpError::Forbidden)
159 }
160 }
161 "designated_role" => {
162 if rules.designated_roles.iter().any(|r| r == sender) {
163 Ok(())
164 } else {
165 Err(MacpError::Forbidden)
166 }
167 }
168 _ => {
169 if sender == session.initiator_sender {
171 Ok(())
172 } else {
173 Err(MacpError::Forbidden)
174 }
175 }
176 }
177 } else {
178 if sender == session.initiator_sender {
180 Ok(())
181 } else {
182 Err(MacpError::Forbidden)
183 }
184 }
185}
186
187fn extract_commitment_rules(
188 rules: &serde_json::Value,
189) -> macp_core::policy::rules::CommitmentRules {
190 macp_core::policy::extract_commitment_rules(rules)
192}
193
194pub fn participants_all_accept(
195 participants: &[String],
196 accepts: &std::collections::BTreeMap<String, String>,
197 proposal_id: &str,
198) -> bool {
199 !participants.is_empty()
200 && participants
201 .iter()
202 .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use macp_pb::pb::CommitmentPayload;
209
210 fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
211 CommitmentPayload {
212 commitment_id: "c1".into(),
213 action: action.into(),
214 authority_scope: "scope".into(),
215 reason: "reason".into(),
216 mode_version: "1.0.0".into(),
217 policy_version: String::new(),
218 configuration_version: "cfg-1".into(),
219 outcome_positive,
220 supersedes: None,
221 }
222 }
223
224 fn session_for_commitment() -> Session {
227 Session::builder("s1", "macp.mode.decision.v1", "agent://a")
228 .ttl_ms(60_000)
229 .mode_version("1.0.0")
230 .configuration_version("cfg-1")
231 .build()
232 }
233
234 #[test]
235 fn well_formed_supersedes_is_accepted() {
236 let session = session_for_commitment();
237 let mut c = make_commitment("decision.selected", true);
238 c.supersedes = Some(macp_pb::pb::CommitmentRef {
239 session_id: "prior-session".into(),
240 commitment_hash: "abc123".into(),
241 });
242 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
243 }
244
245 #[test]
246 fn malformed_supersedes_is_rejected() {
247 let session = session_for_commitment();
248 for bad in [("", "abc123"), ("prior-session", ""), (" ", "abc123")] {
249 let mut c = make_commitment("decision.selected", true);
250 c.supersedes = Some(macp_pb::pb::CommitmentRef {
251 session_id: bad.0.into(),
252 commitment_hash: bad.1.into(),
253 });
254 assert!(
255 validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
256 "expected rejection for supersedes {bad:?}"
257 );
258 }
259 }
260
261 #[test]
267 fn empty_commitment_policy_version_matches_bound_policy() {
268 let mut session = session_for_commitment();
269 session.policy_version = "policy.default".into();
270 let c = make_commitment("decision.selected", true); assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
272 }
273
274 #[test]
275 fn wrong_commitment_policy_version_rejected() {
276 let mut session = session_for_commitment();
277 session.policy_version = "policy.default".into();
278 let mut c = make_commitment("decision.selected", true);
279 c.policy_version = "policy.other.v1".into();
280 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err());
281 }
282
283 #[test]
284 fn exact_commitment_policy_version_accepted() {
285 let mut session = session_for_commitment();
286 session.policy_version = "policy.default".into();
287 let mut c = make_commitment("decision.selected", true);
288 c.policy_version = "policy.default".into();
289 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
290 }
291
292 #[test]
295 fn decision_selected_positive_ok() {
296 assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
297 }
298
299 #[test]
300 fn decision_selected_negative_rejected() {
301 assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
302 }
303
304 #[test]
305 fn decision_rejected_negative_ok() {
306 assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
307 }
308
309 #[test]
310 fn decision_rejected_positive_rejected() {
311 assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
312 }
313
314 #[test]
315 fn proposal_accepted_positive_ok() {
316 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
317 }
318
319 #[test]
320 fn proposal_accepted_negative_rejected() {
321 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
322 }
323
324 #[test]
325 fn proposal_rejected_negative_ok() {
326 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
327 }
328
329 #[test]
330 fn proposal_rejected_positive_rejected() {
331 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
332 }
333
334 #[test]
335 fn task_completed_positive_ok() {
336 assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
337 }
338
339 #[test]
340 fn task_completed_negative_rejected() {
341 assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
342 }
343
344 #[test]
345 fn task_failed_negative_ok() {
346 assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
347 }
348
349 #[test]
350 fn task_failed_positive_rejected() {
351 assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
352 }
353
354 #[test]
355 fn handoff_accepted_positive_ok() {
356 assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
357 }
358
359 #[test]
360 fn handoff_declined_negative_ok() {
361 assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
362 }
363
364 #[test]
365 fn handoff_declined_positive_rejected() {
366 assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
367 }
368
369 #[test]
370 fn quorum_approved_positive_ok() {
371 assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
372 }
373
374 #[test]
375 fn quorum_rejected_negative_ok() {
376 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
377 }
378
379 #[test]
380 fn quorum_rejected_positive_rejected() {
381 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
382 }
383
384 #[test]
385 fn custom_action_no_known_suffix_any_outcome_ok() {
386 assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
388 assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
389 }
390}