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