1use crate::mode::util::{
2 check_commitment_authority, enforce_commitment_policy, is_declared_participant,
3 validate_commitment_payload_for_session,
4};
5use crate::mode::{Mode, ModeResponse};
6use macp_core::error::MacpError;
7use macp_core::session::Session;
8use macp_pb::decision_pb::{EvaluationPayload, ObjectionPayload, ProposalPayload, VotePayload};
9use macp_pb::pb::Envelope;
10use prost::Message;
11use std::collections::BTreeMap;
12
13pub use macp_core::decision::{
17 DecisionPhase, DecisionState, Evaluation, Objection, Proposal, Vote,
18};
19
20pub struct DecisionMode {
21 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
22}
23
24impl DecisionMode {
25 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
27 Self { evaluator }
28 }
29
30 fn default_state() -> DecisionState {
31 DecisionState {
32 proposals: BTreeMap::new(),
33 evaluations: Vec::new(),
34 objections: Vec::new(),
35 votes: BTreeMap::new(),
36 phase: DecisionPhase::Proposal,
37 }
38 }
39
40 fn encode_state(state: &DecisionState) -> Vec<u8> {
41 crate::mode::util::encode_mode_state(state)
42 }
43
44 fn decode_state(data: &[u8]) -> Result<DecisionState, MacpError> {
45 crate::mode::util::decode_mode_state(data)
46 }
47
48 fn ensure_not_committed(state: &DecisionState) -> Result<(), MacpError> {
49 if state.phase == DecisionPhase::Committed {
50 Err(MacpError::SessionNotOpen)
51 } else {
52 Ok(())
53 }
54 }
55
56 fn ensure_known_proposal(state: &DecisionState, proposal_id: &str) -> Result<(), MacpError> {
57 if !state.proposals.contains_key(proposal_id) {
58 return Err(MacpError::InvalidPayload);
59 }
60 Ok(())
61 }
62
63 fn ensure_can_propose(state: &DecisionState) -> Result<(), MacpError> {
64 Self::ensure_not_committed(state)?;
65 if state.phase == DecisionPhase::Voting {
66 return Err(MacpError::InvalidPayload);
67 }
68 Ok(())
69 }
70
71 fn ensure_can_deliberate(state: &DecisionState) -> Result<(), MacpError> {
72 Self::ensure_not_committed(state)?;
73 if state.phase != DecisionPhase::Evaluation {
74 return Err(MacpError::InvalidPayload);
75 }
76 Ok(())
77 }
78
79 fn ensure_can_vote(state: &DecisionState) -> Result<(), MacpError> {
80 Self::ensure_not_committed(state)?;
81 if matches!(
82 state.phase,
83 DecisionPhase::Proposal | DecisionPhase::Committed
84 ) {
85 return Err(MacpError::InvalidPayload);
86 }
87 Ok(())
88 }
89
90 fn commitment_ready(state: &DecisionState) -> bool {
91 !state.proposals.is_empty()
92 }
93}
94
95impl Mode for DecisionMode {
96 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
102 match env.message_type.as_str() {
103 "Commitment" => check_commitment_authority(session, &env.sender),
104 "Proposal" | "Evaluation" | "Objection" | "Vote"
105 if is_declared_participant(&session.participants, &env.sender) =>
106 {
107 Ok(())
108 }
109 _ => Err(MacpError::Forbidden),
110 }
111 }
112
113 fn on_session_start(
134 &self,
135 _session: &Session,
136 _env: &Envelope,
137 ) -> Result<ModeResponse, MacpError> {
138 Ok(ModeResponse::PersistState(Self::encode_state(
139 &Self::default_state(),
140 )))
141 }
142
143 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
144 let mut state = if session.mode_state.is_empty() {
145 Self::default_state()
146 } else {
147 Self::decode_state(&session.mode_state)?
148 };
149
150 Self::ensure_not_committed(&state)?;
151
152 match env.message_type.as_str() {
153 "Proposal" => {
154 Self::ensure_can_propose(&state)?;
155 let payload = ProposalPayload::decode(&*env.payload)
156 .map_err(|_| MacpError::InvalidPayload)?;
157 if payload.proposal_id.trim().is_empty()
158 || payload.option.trim().is_empty()
159 || state.proposals.contains_key(&payload.proposal_id)
160 {
161 return Err(MacpError::InvalidPayload);
162 }
163 state.proposals.insert(
164 payload.proposal_id.clone(),
165 Proposal {
166 proposal_id: payload.proposal_id,
167 option: payload.option,
168 rationale: payload.rationale,
169 sender: env.sender.clone(),
170 },
171 );
172 state.phase = DecisionPhase::Evaluation;
173 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
174 }
175 "Evaluation" => {
176 let payload = EvaluationPayload::decode(&*env.payload)
177 .map_err(|_| MacpError::InvalidPayload)?;
178 match payload.recommendation.to_uppercase().as_str() {
180 "APPROVE" | "REVIEW" | "BLOCK" | "REJECT" => {}
181 _ => return Err(MacpError::InvalidPayload),
182 }
183 if payload.confidence < 0.0 || payload.confidence > 1.0 {
185 return Err(MacpError::InvalidPayload);
186 }
187 Self::ensure_can_deliberate(&state)?;
188 Self::ensure_known_proposal(&state, &payload.proposal_id)?;
189 let normalized_recommendation = payload.recommendation.to_uppercase();
191 state.evaluations.push(Evaluation {
192 proposal_id: payload.proposal_id,
193 recommendation: normalized_recommendation,
194 confidence: payload.confidence,
195 reason: payload.reason,
196 sender: env.sender.clone(),
197 });
198 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
199 }
200 "Objection" => {
201 let payload = ObjectionPayload::decode(&*env.payload)
202 .map_err(|_| MacpError::InvalidPayload)?;
203 let severity = if payload.severity.is_empty() {
205 "medium".into()
206 } else {
207 match payload.severity.to_lowercase().as_str() {
208 "critical" | "high" | "medium" | "low" => payload.severity.to_lowercase(),
209 _ => return Err(MacpError::InvalidPayload),
210 }
211 };
212 Self::ensure_can_deliberate(&state)?;
213 Self::ensure_known_proposal(&state, &payload.proposal_id)?;
214 state.objections.push(Objection {
215 proposal_id: payload.proposal_id,
216 reason: payload.reason,
217 severity,
218 sender: env.sender.clone(),
219 });
220 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
221 }
222 "Vote" => {
223 let payload =
224 VotePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
225 let normalized_vote = payload.vote.to_uppercase();
227 match normalized_vote.as_str() {
228 "APPROVE" | "REJECT" | "ABSTAIN" => {}
229 _ => return Err(MacpError::InvalidPayload),
230 }
231 Self::ensure_can_vote(&state)?;
232 Self::ensure_known_proposal(&state, &payload.proposal_id)?;
233 let proposal_votes = state.votes.entry(payload.proposal_id.clone()).or_default();
234 if proposal_votes.contains_key(&env.sender) {
235 return Err(MacpError::InvalidPayload);
236 }
237 proposal_votes.insert(
238 env.sender.clone(),
239 Vote {
240 proposal_id: payload.proposal_id,
241 vote: normalized_vote,
242 reason: payload.reason,
243 sender: env.sender.clone(),
244 },
245 );
246 state.phase = DecisionPhase::Voting;
247 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
248 }
249 "Commitment" => {
250 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
251 if !Self::commitment_ready(&state) {
252 return Err(MacpError::InvalidPayload);
253 }
254 enforce_commitment_policy(
257 session,
258 macp_core::policy::CommitmentMode::Decision { state: &state },
259 commitment.outcome_positive,
260 &*self.evaluator,
261 )?;
262 state.phase = DecisionPhase::Committed;
263 Ok(ModeResponse::PersistAndResolve {
264 state: Self::encode_state(&state),
265 resolution: env.payload.clone(),
266 })
267 }
268 _ => Err(MacpError::InvalidPayload),
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 use macp_pb::pb::CommitmentPayload;
278
279 fn test_session() -> Session {
280 Session::builder("s1", "macp.mode.decision.v1", "agent://orchestrator")
281 .ttl_ms(60_000)
282 .participants(vec![
283 "agent://orchestrator".into(),
284 "agent://fraud".into(),
285 "agent://growth".into(),
286 ])
287 .mode_version("1.0.0")
288 .configuration_version("cfg-1")
289 .policy_version("policy-1")
290 .build()
291 }
292
293 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
294 Envelope {
295 macp_version: "1.0".into(),
296 mode: "macp.mode.decision.v1".into(),
297 message_type: message_type.into(),
298 message_id: format!("{}-{}", sender, message_type),
299 session_id: "s1".into(),
300 sender: sender.into(),
301 timestamp_unix_ms: 0,
302 payload,
303 }
304 }
305
306 fn proposal(id: &str) -> Vec<u8> {
307 ProposalPayload {
308 proposal_id: id.into(),
309 option: format!("option-{id}"),
310 rationale: "because".into(),
311 supporting_data: vec![],
312 }
313 .encode_to_vec()
314 }
315
316 fn vote(id: &str, value: &str) -> Vec<u8> {
317 VotePayload {
318 proposal_id: id.into(),
319 vote: value.into(),
320 reason: String::new(),
321 }
322 .encode_to_vec()
323 }
324
325 fn evaluation(proposal_id: &str) -> Vec<u8> {
326 EvaluationPayload {
327 proposal_id: proposal_id.into(),
328 recommendation: "APPROVE".into(),
329 confidence: 0.9,
330 reason: "good".into(),
331 }
332 .encode_to_vec()
333 }
334
335 fn objection(proposal_id: &str) -> Vec<u8> {
336 ObjectionPayload {
337 proposal_id: proposal_id.into(),
338 reason: "risky".into(),
339 severity: "high".into(),
340 }
341 .encode_to_vec()
342 }
343
344 fn commitment(session: &Session) -> Vec<u8> {
345 CommitmentPayload {
346 commitment_id: "c1".into(),
347 action: "decision.selected".into(),
348 authority_scope: "payments".into(),
349 reason: "bound".into(),
350 mode_version: session.mode_version.clone(),
351 policy_version: session.policy_version.clone(),
352 configuration_version: session.configuration_version.clone(),
353 outcome_positive: true,
354 supersedes: None,
355 }
356 .encode_to_vec()
357 }
358
359 fn apply(session: &mut Session, response: ModeResponse) {
360 match response {
361 ModeResponse::PersistState(data) => session.mode_state = data,
362 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
363 _ => {}
364 }
365 }
366
367 fn decode(session: &Session) -> DecisionState {
368 serde_json::from_slice(&session.mode_state).unwrap()
369 }
370
371 #[test]
372 fn zero_participant_session_starts() {
373 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
378 let mut session = test_session();
379 session.participants.clear();
380 let response = mode
381 .on_session_start(
382 &session,
383 &env("agent://orchestrator", "SessionStart", vec![]),
384 )
385 .expect("a zero-participant Decision SessionStart must be accepted");
386 assert!(
387 matches!(response, ModeResponse::PersistState(_)),
388 "the mode must still persist its initial state, got: {response:?}"
389 );
390 }
391
392 #[test]
393 fn zero_participant_session_forbids_every_proposal() {
394 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
402 let mut session = test_session();
403 session.participants.clear();
404 for message_type in ["Proposal", "Evaluation", "Objection", "Vote"] {
405 assert_eq!(
406 mode.authorize_sender(
407 &session,
408 &env("agent://orchestrator", message_type, proposal("p1")),
409 )
410 .unwrap_err()
411 .to_string(),
412 "Forbidden",
413 "{message_type} from the initiator must be FORBIDDEN on an empty roster"
414 );
415 }
416 }
417
418 #[test]
419 fn initiator_not_in_participants_cannot_propose() {
420 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
421 let mut session = test_session();
422 session.participants.retain(|p| p != "agent://orchestrator");
423 let err = mode
424 .authorize_sender(
425 &session,
426 &env("agent://orchestrator", "Proposal", proposal("p1")),
427 )
428 .unwrap_err();
429 assert_eq!(err.to_string(), "Forbidden");
430 }
431
432 #[test]
433 fn vote_with_invalid_value_rejected() {
434 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
435 let mut session = test_session();
436 session
437 .participants
438 .push("agent://orchestrator".to_string());
439 let resp = mode
440 .on_session_start(
441 &session,
442 &env("agent://orchestrator", "SessionStart", vec![]),
443 )
444 .unwrap();
445 apply(&mut session, resp);
446 let resp = mode
447 .on_message(
448 &session,
449 &env("agent://orchestrator", "Proposal", proposal("p1")),
450 )
451 .unwrap();
452 apply(&mut session, resp);
453 let bad_vote = VotePayload {
454 proposal_id: "p1".into(),
455 vote: "maybe".into(),
456 reason: String::new(),
457 }
458 .encode_to_vec();
459 let err = mode
460 .on_message(&session, &env("agent://fraud", "Vote", bad_vote))
461 .unwrap_err();
462 assert_eq!(err.to_string(), "InvalidPayload");
463 }
464
465 #[test]
466 fn abstain_vote_accepted() {
467 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
468 let mut session = test_session();
469 session
470 .participants
471 .push("agent://orchestrator".to_string());
472 let resp = mode
473 .on_session_start(
474 &session,
475 &env("agent://orchestrator", "SessionStart", vec![]),
476 )
477 .unwrap();
478 apply(&mut session, resp);
479 let resp = mode
480 .on_message(
481 &session,
482 &env("agent://orchestrator", "Proposal", proposal("p1")),
483 )
484 .unwrap();
485 apply(&mut session, resp);
486 mode.on_message(
487 &session,
488 &env("agent://fraud", "Vote", vote("p1", "abstain")),
489 )
490 .unwrap();
491 }
492
493 #[test]
494 fn evaluation_with_invalid_recommendation_rejected() {
495 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
496 let mut session = test_session();
497 session
498 .participants
499 .push("agent://orchestrator".to_string());
500 let resp = mode
501 .on_session_start(
502 &session,
503 &env("agent://orchestrator", "SessionStart", vec![]),
504 )
505 .unwrap();
506 apply(&mut session, resp);
507 let resp = mode
508 .on_message(
509 &session,
510 &env("agent://orchestrator", "Proposal", proposal("p1")),
511 )
512 .unwrap();
513 apply(&mut session, resp);
514 let bad_eval = EvaluationPayload {
515 proposal_id: "p1".into(),
516 recommendation: "meh".into(),
517 confidence: 0.5,
518 reason: "unclear".into(),
519 }
520 .encode_to_vec();
521 let err = mode
522 .on_message(&session, &env("agent://fraud", "Evaluation", bad_eval))
523 .unwrap_err();
524 assert_eq!(err.to_string(), "InvalidPayload");
525 }
526
527 #[test]
528 fn duplicate_proposal_id_is_rejected() {
529 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
530 let mut session = test_session();
531 let resp = mode
532 .on_session_start(
533 &session,
534 &env("agent://orchestrator", "SessionStart", vec![]),
535 )
536 .unwrap();
537 apply(&mut session, resp);
538 let resp = mode
539 .on_message(
540 &session,
541 &env("agent://orchestrator", "Proposal", proposal("p1")),
542 )
543 .unwrap();
544 apply(&mut session, resp);
545 assert_eq!(
546 mode.on_message(&session, &env("agent://fraud", "Proposal", proposal("p1")))
547 .unwrap_err()
548 .to_string(),
549 "InvalidPayload"
550 );
551 }
552
553 #[test]
554 fn vote_is_scoped_per_proposal() {
555 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
556 let mut session = test_session();
557 let resp = mode
558 .on_session_start(
559 &session,
560 &env("agent://orchestrator", "SessionStart", vec![]),
561 )
562 .unwrap();
563 apply(&mut session, resp);
564 let resp = mode
565 .on_message(
566 &session,
567 &env("agent://orchestrator", "Proposal", proposal("p1")),
568 )
569 .unwrap();
570 apply(&mut session, resp);
571 let resp = mode
572 .on_message(&session, &env("agent://fraud", "Proposal", proposal("p2")))
573 .unwrap();
574 apply(&mut session, resp);
575 let resp = mode
576 .on_message(
577 &session,
578 &env("agent://fraud", "Vote", vote("p1", "approve")),
579 )
580 .unwrap();
581 apply(&mut session, resp);
582 let resp = mode
583 .on_message(
584 &session,
585 &env("agent://fraud", "Vote", vote("p2", "approve")),
586 )
587 .unwrap();
588 apply(&mut session, resp);
589 assert_eq!(
590 mode.on_message(
591 &session,
592 &env("agent://fraud", "Vote", vote("p1", "reject"))
593 )
594 .unwrap_err()
595 .to_string(),
596 "InvalidPayload"
597 );
598 }
599
600 #[test]
601 fn evaluation_before_any_proposal_rejected() {
602 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
603 let mut session = test_session();
604 let resp = mode
605 .on_session_start(
606 &session,
607 &env("agent://orchestrator", "SessionStart", vec![]),
608 )
609 .unwrap();
610 apply(&mut session, resp);
611 assert_eq!(
612 mode.on_message(
613 &session,
614 &env("agent://fraud", "Evaluation", evaluation("p1"))
615 )
616 .unwrap_err()
617 .to_string(),
618 "InvalidPayload"
619 );
620 }
621
622 #[test]
623 fn objection_before_any_proposal_rejected() {
624 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
625 let mut session = test_session();
626 let resp = mode
627 .on_session_start(
628 &session,
629 &env("agent://orchestrator", "SessionStart", vec![]),
630 )
631 .unwrap();
632 apply(&mut session, resp);
633 assert_eq!(
634 mode.on_message(
635 &session,
636 &env("agent://fraud", "Objection", objection("p1"))
637 )
638 .unwrap_err()
639 .to_string(),
640 "InvalidPayload"
641 );
642 }
643
644 #[test]
645 fn vote_before_any_proposal_rejected() {
646 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
647 let mut session = test_session();
648 let resp = mode
649 .on_session_start(
650 &session,
651 &env("agent://orchestrator", "SessionStart", vec![]),
652 )
653 .unwrap();
654 apply(&mut session, resp);
655 assert_eq!(
656 mode.on_message(
657 &session,
658 &env("agent://fraud", "Vote", vote("p1", "approve"))
659 )
660 .unwrap_err()
661 .to_string(),
662 "InvalidPayload"
663 );
664 }
665
666 #[test]
667 fn proposal_after_voting_rejected() {
668 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
669 let mut session = test_session();
670 let resp = mode
671 .on_session_start(
672 &session,
673 &env("agent://orchestrator", "SessionStart", vec![]),
674 )
675 .unwrap();
676 apply(&mut session, resp);
677 let resp = mode
678 .on_message(
679 &session,
680 &env("agent://orchestrator", "Proposal", proposal("p1")),
681 )
682 .unwrap();
683 apply(&mut session, resp);
684 let resp = mode
685 .on_message(
686 &session,
687 &env("agent://fraud", "Vote", vote("p1", "approve")),
688 )
689 .unwrap();
690 apply(&mut session, resp);
691 assert_eq!(
692 mode.on_message(
693 &session,
694 &env("agent://orchestrator", "Proposal", proposal("p2"))
695 )
696 .unwrap_err()
697 .to_string(),
698 "InvalidPayload"
699 );
700 }
701
702 #[test]
703 fn evaluation_after_voting_rejected() {
704 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
705 let mut session = test_session();
706 let resp = mode
707 .on_session_start(
708 &session,
709 &env("agent://orchestrator", "SessionStart", vec![]),
710 )
711 .unwrap();
712 apply(&mut session, resp);
713 let resp = mode
714 .on_message(
715 &session,
716 &env("agent://orchestrator", "Proposal", proposal("p1")),
717 )
718 .unwrap();
719 apply(&mut session, resp);
720 let resp = mode
721 .on_message(
722 &session,
723 &env("agent://fraud", "Vote", vote("p1", "approve")),
724 )
725 .unwrap();
726 apply(&mut session, resp);
727 assert_eq!(
728 mode.on_message(
729 &session,
730 &env("agent://growth", "Evaluation", evaluation("p1"))
731 )
732 .unwrap_err()
733 .to_string(),
734 "InvalidPayload"
735 );
736 }
737
738 #[test]
739 fn objection_after_voting_rejected() {
740 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
741 let mut session = test_session();
742 let resp = mode
743 .on_session_start(
744 &session,
745 &env("agent://orchestrator", "SessionStart", vec![]),
746 )
747 .unwrap();
748 apply(&mut session, resp);
749 let resp = mode
750 .on_message(
751 &session,
752 &env("agent://orchestrator", "Proposal", proposal("p1")),
753 )
754 .unwrap();
755 apply(&mut session, resp);
756 let resp = mode
757 .on_message(
758 &session,
759 &env("agent://fraud", "Vote", vote("p1", "approve")),
760 )
761 .unwrap();
762 apply(&mut session, resp);
763 assert_eq!(
764 mode.on_message(
765 &session,
766 &env("agent://growth", "Objection", objection("p1"))
767 )
768 .unwrap_err()
769 .to_string(),
770 "InvalidPayload"
771 );
772 }
773
774 #[test]
775 fn commitment_from_non_initiator_rejected() {
776 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
777 let mut session = test_session();
778 let resp = mode
779 .on_session_start(
780 &session,
781 &env("agent://orchestrator", "SessionStart", vec![]),
782 )
783 .unwrap();
784 apply(&mut session, resp);
785 let resp = mode
786 .on_message(
787 &session,
788 &env("agent://orchestrator", "Proposal", proposal("p1")),
789 )
790 .unwrap();
791 apply(&mut session, resp);
792 assert_eq!(
793 mode.authorize_sender(
794 &session,
795 &env("agent://fraud", "Commitment", commitment(&session))
796 )
797 .unwrap_err()
798 .to_string(),
799 "Forbidden"
800 );
801 }
802
803 #[test]
804 fn empty_proposal_id_rejected() {
805 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
806 let mut session = test_session();
807 let resp = mode
808 .on_session_start(
809 &session,
810 &env("agent://orchestrator", "SessionStart", vec![]),
811 )
812 .unwrap();
813 apply(&mut session, resp);
814 let empty_proposal = ProposalPayload {
815 proposal_id: "".into(),
816 option: "option".into(),
817 rationale: "because".into(),
818 supporting_data: vec![],
819 }
820 .encode_to_vec();
821 assert_eq!(
822 mode.on_message(
823 &session,
824 &env("agent://orchestrator", "Proposal", empty_proposal)
825 )
826 .unwrap_err()
827 .to_string(),
828 "InvalidPayload"
829 );
830 }
831
832 #[test]
833 fn malformed_vote_payload_rejected() {
834 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
835 let mut session = test_session();
836 let resp = mode
837 .on_session_start(
838 &session,
839 &env("agent://orchestrator", "SessionStart", vec![]),
840 )
841 .unwrap();
842 apply(&mut session, resp);
843 let resp = mode
844 .on_message(
845 &session,
846 &env("agent://orchestrator", "Proposal", proposal("p1")),
847 )
848 .unwrap();
849 apply(&mut session, resp);
850 assert_eq!(
851 mode.on_message(&session, &env("agent://fraud", "Vote", vec![0xff, 0x00]))
852 .unwrap_err()
853 .to_string(),
854 "InvalidPayload"
855 );
856 }
857
858 #[test]
859 fn phase_advances_from_proposal_to_voting_to_committed() {
860 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
861 let mut session = test_session();
862 let resp = mode
863 .on_session_start(
864 &session,
865 &env("agent://orchestrator", "SessionStart", vec![]),
866 )
867 .unwrap();
868 apply(&mut session, resp);
869 assert_eq!(decode(&session).phase, DecisionPhase::Proposal);
870
871 let resp = mode
872 .on_message(
873 &session,
874 &env("agent://orchestrator", "Proposal", proposal("p1")),
875 )
876 .unwrap();
877 apply(&mut session, resp);
878 assert_eq!(decode(&session).phase, DecisionPhase::Evaluation);
879
880 let resp = mode
881 .on_message(
882 &session,
883 &env("agent://fraud", "Vote", vote("p1", "approve")),
884 )
885 .unwrap();
886 apply(&mut session, resp);
887 assert_eq!(decode(&session).phase, DecisionPhase::Voting);
888
889 let resp = mode
890 .on_message(
891 &session,
892 &env("agent://orchestrator", "Commitment", commitment(&session)),
893 )
894 .unwrap();
895 apply(&mut session, resp);
896 assert_eq!(decode(&session).phase, DecisionPhase::Committed);
897 }
898
899 #[test]
900 fn commitment_versions_must_match_session_bindings() {
901 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
902 let mut session = test_session();
903 let resp = mode
904 .on_session_start(
905 &session,
906 &env("agent://orchestrator", "SessionStart", vec![]),
907 )
908 .unwrap();
909 apply(&mut session, resp);
910 let resp = mode
911 .on_message(
912 &session,
913 &env("agent://orchestrator", "Proposal", proposal("p1")),
914 )
915 .unwrap();
916 apply(&mut session, resp);
917
918 let mut bad = CommitmentPayload {
919 commitment_id: "c1".into(),
920 action: "decision.selected".into(),
921 authority_scope: "payments".into(),
922 reason: "bound".into(),
923 mode_version: "wrong".into(),
924 policy_version: session.policy_version.clone(),
925 configuration_version: session.configuration_version.clone(),
926 outcome_positive: true,
927 supersedes: None,
928 }
929 .encode_to_vec();
930
931 assert_eq!(
932 mode.on_message(
933 &session,
934 &env("agent://orchestrator", "Commitment", bad.clone())
935 )
936 .unwrap_err()
937 .to_string(),
938 "InvalidPayload"
939 );
940
941 bad = commitment(&session);
942 mode.on_message(&session, &env("agent://orchestrator", "Commitment", bad))
943 .unwrap();
944 }
945
946 #[test]
947 fn negative_outcome_commitment_succeeds() {
948 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
949 let mut session = test_session();
950 let resp = mode
951 .on_session_start(
952 &session,
953 &env("agent://orchestrator", "SessionStart", vec![]),
954 )
955 .unwrap();
956 apply(&mut session, resp);
957 let resp = mode
959 .on_message(
960 &session,
961 &env("agent://orchestrator", "Proposal", proposal("p1")),
962 )
963 .unwrap();
964 apply(&mut session, resp);
965 let resp = mode
967 .on_message(
968 &session,
969 &env("agent://fraud", "Vote", vote("p1", "reject")),
970 )
971 .unwrap();
972 apply(&mut session, resp);
973 let negative_commitment = CommitmentPayload {
975 commitment_id: "c1".into(),
976 action: "decision.rejected".into(),
977 authority_scope: "payments".into(),
978 reason: "proposal rejected by voters".into(),
979 mode_version: session.mode_version.clone(),
980 policy_version: session.policy_version.clone(),
981 configuration_version: session.configuration_version.clone(),
982 outcome_positive: false,
983 supersedes: None,
984 }
985 .encode_to_vec();
986 let resp = mode
987 .on_message(
988 &session,
989 &env("agent://orchestrator", "Commitment", negative_commitment),
990 )
991 .unwrap();
992 assert!(matches!(resp, ModeResponse::PersistAndResolve { .. }));
993 apply(&mut session, resp);
994 assert_eq!(decode(&session).phase, DecisionPhase::Committed);
995 }
996
997 #[test]
998 fn policy_bound_reject_majority_finalizes_decline() {
999 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1002 let mut session = test_session();
1003 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1004 policy_id: "majority".into(),
1005 mode: "macp.mode.decision.v1".into(),
1006 description: "majority".into(),
1007 rules: serde_json::json!({
1008 "voting": { "algorithm": "majority", "threshold": 0.5 }
1009 }),
1010 schema_version: 1,
1011 });
1012 let resp = mode
1013 .on_session_start(
1014 &session,
1015 &env("agent://orchestrator", "SessionStart", vec![]),
1016 )
1017 .unwrap();
1018 apply(&mut session, resp);
1019 let resp = mode
1020 .on_message(
1021 &session,
1022 &env("agent://orchestrator", "Proposal", proposal("p1")),
1023 )
1024 .unwrap();
1025 apply(&mut session, resp);
1026 let resp = mode
1028 .on_message(
1029 &session,
1030 &env("agent://fraud", "Vote", vote("p1", "reject")),
1031 )
1032 .unwrap();
1033 apply(&mut session, resp);
1034 let resp = mode
1035 .on_message(
1036 &session,
1037 &env("agent://growth", "Vote", vote("p1", "reject")),
1038 )
1039 .unwrap();
1040 apply(&mut session, resp);
1041
1042 let positive = CommitmentPayload {
1044 commitment_id: "c1".into(),
1045 action: "decision.selected".into(),
1046 authority_scope: "payments".into(),
1047 reason: "approved".into(),
1048 mode_version: session.mode_version.clone(),
1049 policy_version: session.policy_version.clone(),
1050 configuration_version: session.configuration_version.clone(),
1051 outcome_positive: true,
1052 supersedes: None,
1053 }
1054 .encode_to_vec();
1055 let err = mode
1056 .on_message(
1057 &session,
1058 &env("agent://orchestrator", "Commitment", positive),
1059 )
1060 .unwrap_err();
1061 assert_eq!(err.to_string(), "PolicyDenied");
1062
1063 let decline = CommitmentPayload {
1065 commitment_id: "c2".into(),
1066 action: "decision.rejected".into(),
1067 authority_scope: "payments".into(),
1068 reason: "rejected by majority".into(),
1069 mode_version: session.mode_version.clone(),
1070 policy_version: session.policy_version.clone(),
1071 configuration_version: session.configuration_version.clone(),
1072 outcome_positive: false,
1073 supersedes: None,
1074 }
1075 .encode_to_vec();
1076 let resp = mode
1077 .on_message(
1078 &session,
1079 &env("agent://orchestrator", "Commitment", decline),
1080 )
1081 .unwrap();
1082 let resolution = match &resp {
1083 ModeResponse::PersistAndResolve { resolution, .. } => resolution.clone(),
1084 other => panic!("expected PersistAndResolve, got {other:?}"),
1085 };
1086 let resolved = CommitmentPayload::decode(resolution.as_slice()).unwrap();
1087 assert!(!resolved.outcome_positive);
1088 assert_eq!(resolved.action, "decision.rejected");
1089 apply(&mut session, resp);
1090 assert_eq!(decode(&session).phase, DecisionPhase::Committed);
1091 }
1092
1093 #[test]
1094 fn policy_denies_commitment_when_vote_threshold_not_met() {
1095 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1096 let mut session = test_session();
1097 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1098 policy_id: "test-strict".into(),
1099 mode: "macp.mode.decision.v1".into(),
1100 description: "strict".into(),
1101 rules: serde_json::json!({
1102 "voting": { "algorithm": "unanimous" }
1103 }),
1104 schema_version: 1,
1105 });
1106 let resp = mode
1107 .on_session_start(
1108 &session,
1109 &env("agent://orchestrator", "SessionStart", vec![]),
1110 )
1111 .unwrap();
1112 apply(&mut session, resp);
1113 let resp = mode
1114 .on_message(
1115 &session,
1116 &env("agent://orchestrator", "Proposal", proposal("p1")),
1117 )
1118 .unwrap();
1119 apply(&mut session, resp);
1120 let resp = mode
1122 .on_message(
1123 &session,
1124 &env("agent://fraud", "Vote", vote("p1", "approve")),
1125 )
1126 .unwrap();
1127 apply(&mut session, resp);
1128 let err = mode
1130 .on_message(
1131 &session,
1132 &env("agent://orchestrator", "Commitment", commitment(&session)),
1133 )
1134 .unwrap_err();
1135 assert_eq!(err.to_string(), "PolicyDenied");
1136 }
1137
1138 struct DenyAllEvaluator;
1143
1144 impl macp_core::policy::PolicyEvaluator for DenyAllEvaluator {
1145 fn evaluate_commitment(
1146 &self,
1147 ctx: &macp_core::policy::CommitmentContext<'_>,
1148 ) -> macp_core::policy::PolicyDecision {
1149 match ctx.mode {
1150 macp_core::policy::CommitmentMode::Decision { .. } => {
1151 macp_core::policy::PolicyDecision::Deny {
1152 reasons: vec!["custom evaluator denies all".into()],
1153 }
1154 }
1155 _ => macp_core::policy::PolicyDecision::Allow { reasons: vec![] },
1156 }
1157 }
1158 }
1159
1160 fn session_ready_for_commitment(mode: &DecisionMode) -> (Session, Envelope) {
1165 let mut session = test_session();
1166 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1170 policy_id: "permissive".into(),
1171 mode: "macp.mode.decision.v1".into(),
1172 description: "no extra constraints".into(),
1173 rules: serde_json::json!({}),
1174 schema_version: 1,
1175 });
1176 let resp = mode
1177 .on_session_start(
1178 &session,
1179 &env("agent://orchestrator", "SessionStart", vec![]),
1180 )
1181 .unwrap();
1182 apply(&mut session, resp);
1183 let resp = mode
1184 .on_message(
1185 &session,
1186 &env("agent://orchestrator", "Proposal", proposal("p1")),
1187 )
1188 .unwrap();
1189 apply(&mut session, resp);
1190 let commit = env("agent://orchestrator", "Commitment", commitment(&session));
1191 (session, commit)
1192 }
1193
1194 #[test]
1195 fn injected_evaluator_governs_commitment_outcome() {
1196 let default_mode =
1202 DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1203 let (session, commit) = session_ready_for_commitment(&default_mode);
1204 let resp = default_mode.on_message(&session, &commit).unwrap();
1205 assert!(matches!(resp, ModeResponse::PersistAndResolve { .. }));
1206
1207 let custom_mode = DecisionMode::new(std::sync::Arc::new(DenyAllEvaluator));
1209 let (session, commit) = session_ready_for_commitment(&custom_mode);
1210 let err = custom_mode.on_message(&session, &commit).unwrap_err();
1211 assert_eq!(err.to_string(), "PolicyDenied");
1212 }
1213
1214 #[test]
1215 fn evaluation_confidence_out_of_bounds_rejected() {
1216 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1217 let mut session = test_session();
1218 let resp = mode
1219 .on_session_start(
1220 &session,
1221 &env("agent://orchestrator", "SessionStart", vec![]),
1222 )
1223 .unwrap();
1224 apply(&mut session, resp);
1225 let resp = mode
1226 .on_message(
1227 &session,
1228 &env("agent://orchestrator", "Proposal", proposal("p1")),
1229 )
1230 .unwrap();
1231 apply(&mut session, resp);
1232 let bad_eval = EvaluationPayload {
1233 proposal_id: "p1".into(),
1234 recommendation: "APPROVE".into(),
1235 confidence: 1.5,
1236 reason: "too confident".into(),
1237 }
1238 .encode_to_vec();
1239 assert_eq!(
1240 mode.on_message(&session, &env("agent://fraud", "Evaluation", bad_eval))
1241 .unwrap_err()
1242 .to_string(),
1243 "InvalidPayload"
1244 );
1245 }
1246
1247 #[test]
1248 fn evaluation_confidence_negative_rejected() {
1249 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1250 let mut session = test_session();
1251 let resp = mode
1252 .on_session_start(
1253 &session,
1254 &env("agent://orchestrator", "SessionStart", vec![]),
1255 )
1256 .unwrap();
1257 apply(&mut session, resp);
1258 let resp = mode
1259 .on_message(
1260 &session,
1261 &env("agent://orchestrator", "Proposal", proposal("p1")),
1262 )
1263 .unwrap();
1264 apply(&mut session, resp);
1265 let bad_eval = EvaluationPayload {
1266 proposal_id: "p1".into(),
1267 recommendation: "APPROVE".into(),
1268 confidence: -0.1,
1269 reason: "negative".into(),
1270 }
1271 .encode_to_vec();
1272 assert_eq!(
1273 mode.on_message(&session, &env("agent://fraud", "Evaluation", bad_eval))
1274 .unwrap_err()
1275 .to_string(),
1276 "InvalidPayload"
1277 );
1278 }
1279
1280 #[test]
1281 fn evaluation_confidence_boundary_accepted() {
1282 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1283 let mut session = test_session();
1284 let resp = mode
1285 .on_session_start(
1286 &session,
1287 &env("agent://orchestrator", "SessionStart", vec![]),
1288 )
1289 .unwrap();
1290 apply(&mut session, resp);
1291 let resp = mode
1292 .on_message(
1293 &session,
1294 &env("agent://orchestrator", "Proposal", proposal("p1")),
1295 )
1296 .unwrap();
1297 apply(&mut session, resp);
1298 let eval_zero = EvaluationPayload {
1300 proposal_id: "p1".into(),
1301 recommendation: "APPROVE".into(),
1302 confidence: 0.0,
1303 reason: "zero".into(),
1304 }
1305 .encode_to_vec();
1306 let resp = mode
1307 .on_message(&session, &env("agent://fraud", "Evaluation", eval_zero))
1308 .unwrap();
1309 apply(&mut session, resp);
1310 let eval_one = EvaluationPayload {
1312 proposal_id: "p1".into(),
1313 recommendation: "REVIEW".into(),
1314 confidence: 1.0,
1315 reason: "one".into(),
1316 }
1317 .encode_to_vec();
1318 mode.on_message(&session, &env("agent://growth", "Evaluation", eval_one))
1319 .unwrap();
1320 }
1321
1322 #[test]
1323 fn objection_invalid_severity_rejected() {
1324 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1325 let mut session = test_session();
1326 let resp = mode
1327 .on_session_start(
1328 &session,
1329 &env("agent://orchestrator", "SessionStart", vec![]),
1330 )
1331 .unwrap();
1332 apply(&mut session, resp);
1333 let resp = mode
1334 .on_message(
1335 &session,
1336 &env("agent://orchestrator", "Proposal", proposal("p1")),
1337 )
1338 .unwrap();
1339 apply(&mut session, resp);
1340 let bad_objection = ObjectionPayload {
1341 proposal_id: "p1".into(),
1342 reason: "bad".into(),
1343 severity: "urgent".into(),
1344 }
1345 .encode_to_vec();
1346 assert_eq!(
1347 mode.on_message(&session, &env("agent://fraud", "Objection", bad_objection))
1348 .unwrap_err()
1349 .to_string(),
1350 "InvalidPayload"
1351 );
1352 }
1353
1354 #[test]
1355 fn objection_valid_severities_accepted() {
1356 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1357 let mut session = test_session();
1358 let resp = mode
1359 .on_session_start(
1360 &session,
1361 &env("agent://orchestrator", "SessionStart", vec![]),
1362 )
1363 .unwrap();
1364 apply(&mut session, resp);
1365 let resp = mode
1366 .on_message(
1367 &session,
1368 &env("agent://orchestrator", "Proposal", proposal("p1")),
1369 )
1370 .unwrap();
1371 apply(&mut session, resp);
1372 for severity in &["critical", "high", "medium", "low"] {
1373 let obj = ObjectionPayload {
1374 proposal_id: "p1".into(),
1375 reason: "reason".into(),
1376 severity: severity.to_string(),
1377 }
1378 .encode_to_vec();
1379 let resp = mode
1380 .on_message(&session, &env("agent://fraud", "Objection", obj))
1381 .unwrap();
1382 apply(&mut session, resp);
1383 }
1384 }
1385
1386 #[test]
1387 fn objection_severity_case_normalized() {
1388 let mode = DecisionMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1389 let mut session = test_session();
1390 let resp = mode
1391 .on_session_start(
1392 &session,
1393 &env("agent://orchestrator", "SessionStart", vec![]),
1394 )
1395 .unwrap();
1396 apply(&mut session, resp);
1397 let resp = mode
1398 .on_message(
1399 &session,
1400 &env("agent://orchestrator", "Proposal", proposal("p1")),
1401 )
1402 .unwrap();
1403 apply(&mut session, resp);
1404 let obj = ObjectionPayload {
1405 proposal_id: "p1".into(),
1406 reason: "reason".into(),
1407 severity: "CRITICAL".into(),
1408 }
1409 .encode_to_vec();
1410 let resp = mode
1411 .on_message(&session, &env("agent://fraud", "Objection", obj))
1412 .unwrap();
1413 apply(&mut session, resp);
1414 let state = decode(&session);
1415 assert_eq!(state.objections[0].severity, "critical");
1416 }
1417}