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 {
53 if sup.session_id.trim().is_empty() {
54 tracing::warn!(
55 session_id = %sup.session_id,
56 "supersedes.session_id must be non-empty"
57 );
58 return Err(MacpError::InvalidPayload);
59 }
60 if !is_canonical_commitment_hash(&sup.commitment_hash) {
61 tracing::warn!(
62 commitment_hash = %sup.commitment_hash,
63 "supersedes.commitment_hash must be a canonical RFC-MACP-0013 hash: \
64 'sha256:' followed by 64 lowercase hex characters"
65 );
66 return Err(MacpError::InvalidPayload);
67 }
68 }
69
70 validate_outcome_positive(&commitment)?;
72
73 Ok(commitment)
74}
75
76#[doc(hidden)]
86pub fn is_canonical_commitment_hash(s: &str) -> bool {
87 match s.strip_prefix("sha256:") {
88 Some(rest) => {
89 rest.len() == 64
90 && rest
91 .bytes()
92 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
93 }
94 None => false,
95 }
96}
97
98fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
102 let action = commitment.action.as_str();
103 let negative_actions = ["rejected", "failed", "declined"];
104 let positive_actions = ["selected", "accepted", "completed", "approved"];
105
106 let is_negative = negative_actions
107 .iter()
108 .any(|suffix| action.ends_with(suffix));
109 let is_positive = positive_actions
110 .iter()
111 .any(|suffix| action.ends_with(suffix));
112
113 if is_negative && commitment.outcome_positive {
114 return Err(MacpError::InvalidPayload);
115 }
116 if is_positive && !commitment.outcome_positive {
117 return Err(MacpError::InvalidPayload);
118 }
119 Ok(())
120}
121
122pub fn enforce_commitment_policy(
126 session: &Session,
127 mode: macp_core::policy::CommitmentMode<'_>,
128 outcome_positive: bool,
129 evaluator: &dyn macp_core::policy::PolicyEvaluator,
130) -> Result<(), MacpError> {
131 let Some(ref policy) = session.policy_definition else {
132 return Ok(());
133 };
134 let decision = evaluator.evaluate_commitment(&macp_core::policy::CommitmentContext {
135 policy,
136 participants: &session.participants,
137 outcome_positive,
138 mode,
139 });
140 match decision {
141 macp_core::policy::PolicyDecision::Allow { .. } => Ok(()),
142 macp_core::policy::PolicyDecision::Deny { reasons } => {
143 tracing::warn!(
144 session_id = %session.session_id,
145 policy_id = %policy.policy_id,
146 reasons = ?reasons,
147 "policy denied commitment"
148 );
149 Err(MacpError::PolicyDenied { reasons })
150 }
151 other => {
152 tracing::warn!(
153 session_id = %session.session_id,
154 policy_id = %policy.policy_id,
155 decision = ?other,
156 "unrecognized policy decision treated as denial"
157 );
158 Err(MacpError::PolicyDenied {
159 reasons: vec!["unrecognized policy decision".into()],
160 })
161 }
162 }
163}
164
165pub fn encode_mode_state<T: serde::Serialize>(state: &T) -> Vec<u8> {
169 serde_json::to_vec(state).expect("mode state is always serializable")
170}
171
172pub fn decode_mode_state<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MacpError> {
173 serde_json::from_slice(bytes).map_err(|_| MacpError::InvalidModeState)
174}
175
176pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
177 participants.iter().any(|participant| participant == sender)
178}
179
180pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
185 if let Some(ref policy) = session.policy_definition {
186 let rules: macp_core::policy::rules::CommitmentRules =
187 extract_commitment_rules(&policy.rules);
188 match rules.authority.as_str() {
189 "any_participant" => {
190 if sender == session.initiator_sender
191 || is_declared_participant(&session.participants, sender)
192 {
193 Ok(())
194 } else {
195 Err(MacpError::Forbidden)
196 }
197 }
198 "designated_role" => {
199 if rules.designated_roles.iter().any(|r| r == sender) {
200 Ok(())
201 } else {
202 Err(MacpError::Forbidden)
203 }
204 }
205 _ => {
206 if sender == session.initiator_sender {
208 Ok(())
209 } else {
210 Err(MacpError::Forbidden)
211 }
212 }
213 }
214 } else {
215 if sender == session.initiator_sender {
217 Ok(())
218 } else {
219 Err(MacpError::Forbidden)
220 }
221 }
222}
223
224fn extract_commitment_rules(
225 rules: &serde_json::Value,
226) -> macp_core::policy::rules::CommitmentRules {
227 macp_core::policy::extract_commitment_rules(rules)
229}
230
231pub fn participants_all_accept(
232 participants: &[String],
233 accepts: &std::collections::BTreeMap<String, String>,
234 proposal_id: &str,
235) -> bool {
236 !participants.is_empty()
237 && participants
238 .iter()
239 .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use macp_pb::pb::CommitmentPayload;
246
247 fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
248 CommitmentPayload {
249 commitment_id: "c1".into(),
250 action: action.into(),
251 authority_scope: "scope".into(),
252 reason: "reason".into(),
253 mode_version: "1.0.0".into(),
254 policy_version: String::new(),
255 configuration_version: "cfg-1".into(),
256 outcome_positive,
257 supersedes: None,
258 }
259 }
260
261 fn session_for_commitment() -> Session {
264 Session::builder("s1", "macp.mode.decision.v1", "agent://a")
265 .ttl_ms(60_000)
266 .mode_version("1.0.0")
267 .configuration_version("cfg-1")
268 .build()
269 }
270
271 const VALID_COMMITMENT_HASH: &str =
275 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41";
276
277 #[test]
278 fn well_formed_supersedes_is_accepted() {
279 let session = session_for_commitment();
280 let mut c = make_commitment("decision.selected", true);
281 c.supersedes = Some(macp_pb::pb::CommitmentRef {
282 session_id: "prior-session".into(),
283 commitment_hash: VALID_COMMITMENT_HASH.into(),
284 });
285 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
286 }
287
288 #[test]
289 fn malformed_supersedes_is_rejected() {
290 let session = session_for_commitment();
291 for bad in [
292 ("", VALID_COMMITMENT_HASH),
293 ("prior-session", ""),
294 (" ", VALID_COMMITMENT_HASH),
295 ("prior-session", "not-a-hash"),
297 (
299 "prior-session",
300 "sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41",
301 ),
302 (
304 "prior-session",
305 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4",
306 ),
307 ] {
308 let mut c = make_commitment("decision.selected", true);
309 c.supersedes = Some(macp_pb::pb::CommitmentRef {
310 session_id: bad.0.into(),
311 commitment_hash: bad.1.into(),
312 });
313 assert!(
314 validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
315 "expected rejection for supersedes {bad:?}"
316 );
317 }
318 }
319
320 #[test]
323 fn canonical_hash_valid_is_accepted() {
324 assert!(is_canonical_commitment_hash(VALID_COMMITMENT_HASH));
325 }
326
327 #[test]
328 fn canonical_hash_rejects_uppercase() {
329 assert!(!is_canonical_commitment_hash(
330 "sha256:9F58E9D114D11860D48AA2BCB8CDA458B9618B1CC8560595A802B68C4AF85D41"
331 ));
332 }
333
334 #[test]
335 fn canonical_hash_rejects_uppercase_prefix() {
336 assert!(!is_canonical_commitment_hash(&format!(
339 "SHA256:{}",
340 VALID_COMMITMENT_HASH.strip_prefix("sha256:").unwrap()
341 )));
342 }
343
344 #[test]
345 fn canonical_hash_rejects_63_chars() {
346 assert!(!is_canonical_commitment_hash(
347 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4"
348 ));
349 }
350
351 #[test]
352 fn canonical_hash_rejects_65_chars() {
353 assert!(!is_canonical_commitment_hash(
354 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d411"
355 ));
356 }
357
358 #[test]
359 fn canonical_hash_rejects_missing_prefix() {
360 assert!(!is_canonical_commitment_hash(
361 "9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
362 ));
363 }
364
365 #[test]
366 fn canonical_hash_rejects_wrong_prefix() {
367 assert!(!is_canonical_commitment_hash(
368 "sha512:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
369 ));
370 }
371
372 #[test]
373 fn canonical_hash_rejects_empty_string() {
374 assert!(!is_canonical_commitment_hash(""));
375 }
376
377 #[test]
378 fn canonical_hash_rejects_leading_whitespace() {
379 assert!(!is_canonical_commitment_hash(&format!(
380 " {VALID_COMMITMENT_HASH}"
381 )));
382 }
383
384 #[test]
385 fn canonical_hash_rejects_trailing_whitespace() {
386 assert!(!is_canonical_commitment_hash(&format!(
387 "{VALID_COMMITMENT_HASH} "
388 )));
389 }
390
391 #[test]
392 fn canonical_hash_rejects_non_hex_characters() {
393 assert!(!is_canonical_commitment_hash(
395 "sha256:gf58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41"
396 ));
397 assert!(!is_canonical_commitment_hash(
399 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d4!"
400 ));
401 }
402
403 #[test]
409 fn empty_commitment_policy_version_matches_bound_policy() {
410 let mut session = session_for_commitment();
411 session.policy_version = "policy.default".into();
412 let c = make_commitment("decision.selected", true); assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
414 }
415
416 #[test]
417 fn wrong_commitment_policy_version_rejected() {
418 let mut session = session_for_commitment();
419 session.policy_version = "policy.default".into();
420 let mut c = make_commitment("decision.selected", true);
421 c.policy_version = "policy.other.v1".into();
422 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err());
423 }
424
425 #[test]
426 fn exact_commitment_policy_version_accepted() {
427 let mut session = session_for_commitment();
428 session.policy_version = "policy.default".into();
429 let mut c = make_commitment("decision.selected", true);
430 c.policy_version = "policy.default".into();
431 assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
432 }
433
434 #[test]
437 fn decision_selected_positive_ok() {
438 assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
439 }
440
441 #[test]
442 fn decision_selected_negative_rejected() {
443 assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
444 }
445
446 #[test]
447 fn decision_rejected_negative_ok() {
448 assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
449 }
450
451 #[test]
452 fn decision_rejected_positive_rejected() {
453 assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
454 }
455
456 #[test]
457 fn proposal_accepted_positive_ok() {
458 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
459 }
460
461 #[test]
462 fn proposal_accepted_negative_rejected() {
463 assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
464 }
465
466 #[test]
467 fn proposal_rejected_negative_ok() {
468 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
469 }
470
471 #[test]
472 fn proposal_rejected_positive_rejected() {
473 assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
474 }
475
476 #[test]
477 fn task_completed_positive_ok() {
478 assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
479 }
480
481 #[test]
482 fn task_completed_negative_rejected() {
483 assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
484 }
485
486 #[test]
487 fn task_failed_negative_ok() {
488 assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
489 }
490
491 #[test]
492 fn task_failed_positive_rejected() {
493 assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
494 }
495
496 #[test]
497 fn handoff_accepted_positive_ok() {
498 assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
499 }
500
501 #[test]
502 fn handoff_declined_negative_ok() {
503 assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
504 }
505
506 #[test]
507 fn handoff_declined_positive_rejected() {
508 assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
509 }
510
511 #[test]
512 fn quorum_approved_positive_ok() {
513 assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
514 }
515
516 #[test]
517 fn quorum_rejected_negative_ok() {
518 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
519 }
520
521 #[test]
522 fn quorum_rejected_positive_rejected() {
523 assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
524 }
525
526 #[test]
527 fn custom_action_no_known_suffix_any_outcome_ok() {
528 assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
530 assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
531 }
532}