Skip to main content

macp_modes/mode/
decision.rs

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
13// The Decision mode's domain types now live in `macp-core` (the policy
14// evaluation trait names them). Re-exported so `crate::mode::decision::*` and
15// downstream `macp_runtime::mode::decision::*` paths keep resolving.
16pub 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    /// Construct the mode with an injected governance policy evaluator.
26    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    /// Authorize the sender for decision mode messages.
97    ///
98    /// Authority matrix (RFC-MACP-0004):
99    /// - Proposal, Evaluation, Objection, Vote → declared participant only
100    /// - Commitment → initiator or policy-delegated role
101    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                // RFC-MACP-0004: valid recommendation values
162                match payload.recommendation.to_uppercase().as_str() {
163                    "APPROVE" | "REVIEW" | "BLOCK" | "REJECT" => {}
164                    _ => return Err(MacpError::InvalidPayload),
165                }
166                // RFC-MACP-0004 §2.2: confidence must be a normalized value in [0.0, 1.0]
167                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                // RFC-MACP-0007 §4: all enum-like values MUST be stored in UPPER_CASE
173                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                // RFC-MACP-0004 §2.3: severity must be one of {critical, high, medium, low}
187                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                // RFC-MACP-0007: valid vote values (case-insensitive input, stored UPPERCASE)
209                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                // Governance policy gate (shared): fail closed, only
238                // an explicit Allow proceeds.
239                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        // Add a proposal
910        let resp = mode
911            .on_message(
912                &session,
913                &env("agent://orchestrator", "Proposal", proposal("p1")),
914            )
915            .unwrap();
916        apply(&mut session, resp);
917        // Add a vote (reject)
918        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        // Commit with negative outcome
926        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        // RFC-MACP-0007 §6: a reject-majority under a bound policy must finalize
952        // a negative outcome rather than being denied (the motivating bug).
953        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        // Reject-majority: both voting participants reject.
979        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        // A positive commitment over a reject-majority is still denied...
995        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        // ...but a decline finalizes and resolves the session.
1016        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        // Only one of the participants votes approve
1073        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        // Commitment should be denied by policy (unanimous requires all participants)
1081        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    // A consumer-supplied evaluator that denies every decision commitment,
1091    // regardless of votes or rules. Used to prove that modes consult the
1092    // injected `PolicyEvaluator` rather than any built-in default — the seam
1093    // the workspace split exists to provide.
1094    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    // Drive a decision session up to (but not including) the commitment, using a
1113    // permissive policy the default evaluator would accept. Returns the session
1114    // and the commitment envelope so a test can swap evaluators and assert that
1115    // only the injected one changes the outcome.
1116    fn session_ready_for_commitment(mode: &DecisionMode) -> (Session, Envelope) {
1117        let mut session = test_session();
1118        // A policy with no additional voting constraints: the default evaluator
1119        // allows the commitment once a proposal exists, so any denial below can
1120        // only come from the injected evaluator.
1121        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        // Same permissive policy, same session timeline — only the injected
1149        // evaluator differs. This is the pluggable-policy seam introduced by the
1150        // workspace split: modes call through `macp_core::PolicyEvaluator`.
1151
1152        // Default evaluator: permissive policy allows the commitment.
1153        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        // Custom evaluator: identical inputs, but the consumer's policy denies.
1160        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        // confidence=0.0 should be accepted
1251        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        // confidence=1.0 should be accepted
1263        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}