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