Skip to main content

macp_modes/mode/
decision.rs

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
12// The Decision mode's domain types now live in `macp-core` (the policy
13// evaluation trait names them). Re-exported so `crate::mode::decision::*` and
14// downstream `macp_runtime::mode::decision::*` paths keep resolving.
15pub 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    /// Construct the mode with an injected governance policy evaluator.
25    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    /// Authorize the sender for decision mode messages.
96    ///
97    /// Authority matrix (RFC-MACP-0004):
98    /// - Proposal, Evaluation, Objection, Vote → declared participant only
99    /// - Commitment → initiator or policy-delegated role
100    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                // RFC-MACP-0004: valid recommendation values
161                match payload.recommendation.to_uppercase().as_str() {
162                    "APPROVE" | "REVIEW" | "BLOCK" | "REJECT" => {}
163                    _ => return Err(MacpError::InvalidPayload),
164                }
165                // RFC-MACP-0004 §2.2: confidence must be a normalized value in [0.0, 1.0]
166                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                // RFC-MACP-0007 §4: all enum-like values MUST be stored in UPPER_CASE
172                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                // RFC-MACP-0004 §2.3: severity must be one of {critical, high, medium, low}
186                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                // RFC-MACP-0007: valid vote values (case-insensitive input, stored UPPERCASE)
208                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                // Evaluate governance policy if one is bound to the session.
237                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        // Add a proposal
937        let resp = mode
938            .on_message(
939                &session,
940                &env("agent://orchestrator", "Proposal", proposal("p1")),
941            )
942            .unwrap();
943        apply(&mut session, resp);
944        // Add a vote (reject)
945        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        // Commit with negative outcome
953        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        // Only one of the participants votes approve
1004        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        // Commitment should be denied by policy (unanimous requires all participants)
1012        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    // A consumer-supplied evaluator that denies every decision commitment,
1022    // regardless of votes or rules. Used to prove that modes consult the
1023    // injected `PolicyEvaluator` rather than any built-in default — the seam
1024    // the workspace split exists to provide.
1025    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    // Drive a decision session up to (but not including) the commitment, using a
1075    // permissive policy the default evaluator would accept. Returns the session
1076    // and the commitment envelope so a test can swap evaluators and assert that
1077    // only the injected one changes the outcome.
1078    fn session_ready_for_commitment(mode: &DecisionMode) -> (Session, Envelope) {
1079        let mut session = test_session();
1080        // A policy with no additional voting constraints: the default evaluator
1081        // allows the commitment once a proposal exists, so any denial below can
1082        // only come from the injected evaluator.
1083        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        // Same permissive policy, same session timeline — only the injected
1111        // evaluator differs. This is the pluggable-policy seam introduced by the
1112        // workspace split: modes call through `macp_core::PolicyEvaluator`.
1113
1114        // Default evaluator: permissive policy allows the commitment.
1115        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        // Custom evaluator: identical inputs, but the consumer's policy denies.
1122        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        // confidence=0.0 should be accepted
1213        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        // confidence=1.0 should be accepted
1225        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}