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