Skip to main content

macp_modes/mode/
quorum.rs

1use crate::mode::util::{
2    check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session,
3};
4use crate::mode::{Mode, ModeResponse};
5use macp_core::error::MacpError;
6use macp_core::session::Session;
7use macp_pb::pb::Envelope;
8use macp_pb::quorum_pb::{AbstainPayload, ApprovalRequestPayload, ApprovePayload, RejectPayload};
9use prost::Message;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub enum BallotChoice {
15    Approve,
16    Reject,
17    Abstain,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ApprovalRequestRecord {
22    pub request_id: String,
23    pub action: String,
24    pub summary: String,
25    pub details: Vec<u8>,
26    pub required_approvals: u32,
27    pub requested_by: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct BallotRecord {
32    pub request_id: String,
33    pub choice: BallotChoice,
34    pub sender: String,
35    pub reason: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct QuorumState {
40    pub request: Option<ApprovalRequestRecord>,
41    pub ballots: BTreeMap<String, BallotRecord>,
42}
43
44pub struct QuorumMode {
45    evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
46}
47
48impl QuorumMode {
49    /// Construct the mode with an injected governance policy evaluator.
50    pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
51        Self { evaluator }
52    }
53
54    fn encode_state(state: &QuorumState) -> Vec<u8> {
55        serde_json::to_vec(state).expect("QuorumState is always serializable")
56    }
57
58    fn decode_state(data: &[u8]) -> Result<QuorumState, MacpError> {
59        serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState)
60    }
61
62    /// Resolve the effective approval threshold, considering policy overrides.
63    ///
64    /// RFC-MACP-0011: "When policy specifies a threshold override, it replaces
65    /// (not supplements) the required_approvals value from ApprovalRequest."
66    fn effective_threshold(session: &Session, request: &ApprovalRequestRecord) -> u32 {
67        if let Some(ref policy) = session.policy_definition {
68            let rules: macp_core::policy::rules::QuorumPolicyRules =
69                serde_json::from_value(policy.rules.clone()).unwrap_or_default();
70            if rules.threshold.value > 0.0 {
71                return match rules.threshold.threshold_type.as_str() {
72                    "percentage" => {
73                        let n = session.participants.len() as f64;
74                        (rules.threshold.value / 100.0 * n).ceil() as u32
75                    }
76                    // "n_of_m" or "count" — use value directly
77                    _ => rules.threshold.value as u32,
78                };
79            }
80        }
81        request.required_approvals
82    }
83
84    fn commitment_ready(session: &Session, state: &QuorumState) -> bool {
85        let request = match &state.request {
86            Some(request) => request,
87            None => return false,
88        };
89        let required = Self::effective_threshold(session, request);
90        let approvals = state
91            .ballots
92            .values()
93            .filter(|ballot| ballot.choice == BallotChoice::Approve)
94            .count() as u32;
95        let total_eligible = session.participants.len() as u32;
96        let counted = state.ballots.len() as u32;
97        let remaining = total_eligible.saturating_sub(counted);
98        // Commitment is ready if threshold reached OR threshold is mathematically unreachable
99        approvals >= required || approvals + remaining < required
100    }
101}
102
103impl Mode for QuorumMode {
104    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
105        match env.message_type.as_str() {
106            "ApprovalRequest" if env.sender == session.initiator_sender => Ok(()),
107            "ApprovalRequest" => Err(MacpError::Forbidden),
108            "Commitment" => check_commitment_authority(session, &env.sender),
109            _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
110            _ => Err(MacpError::Forbidden),
111        }
112    }
113
114    fn on_session_start(
115        &self,
116        session: &Session,
117        _env: &Envelope,
118    ) -> Result<ModeResponse, MacpError> {
119        if session.participants.is_empty() {
120            return Err(MacpError::InvalidPayload);
121        }
122        Ok(ModeResponse::PersistState(Self::encode_state(
123            &QuorumState::default(),
124        )))
125    }
126
127    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
128        let mut state = if session.mode_state.is_empty() {
129            QuorumState::default()
130        } else {
131            Self::decode_state(&session.mode_state)?
132        };
133
134        match env.message_type.as_str() {
135            "ApprovalRequest" => {
136                if env.sender != session.initiator_sender {
137                    return Err(MacpError::Forbidden);
138                }
139                let payload = ApprovalRequestPayload::decode(&*env.payload)
140                    .map_err(|_| MacpError::InvalidPayload)?;
141                if state.request.is_some()
142                    || payload.request_id.is_empty()
143                    || payload.required_approvals == 0
144                    || payload.required_approvals > session.participants.len() as u32
145                {
146                    return Err(MacpError::InvalidPayload);
147                }
148                state.request = Some(ApprovalRequestRecord {
149                    request_id: payload.request_id,
150                    action: payload.action,
151                    summary: payload.summary,
152                    details: payload.details,
153                    required_approvals: payload.required_approvals,
154                    requested_by: env.sender.clone(),
155                });
156                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
157            }
158            "Approve" => {
159                let payload =
160                    ApprovePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
161                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
162                if payload.request_id != request.request_id
163                    || state.ballots.contains_key(&env.sender)
164                {
165                    return Err(MacpError::InvalidPayload);
166                }
167                state.ballots.insert(
168                    env.sender.clone(),
169                    BallotRecord {
170                        request_id: payload.request_id,
171                        choice: BallotChoice::Approve,
172                        sender: env.sender.clone(),
173                        reason: payload.reason,
174                    },
175                );
176                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
177            }
178            "Reject" => {
179                let payload =
180                    RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
181                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
182                if payload.request_id != request.request_id
183                    || state.ballots.contains_key(&env.sender)
184                {
185                    return Err(MacpError::InvalidPayload);
186                }
187                state.ballots.insert(
188                    env.sender.clone(),
189                    BallotRecord {
190                        request_id: payload.request_id,
191                        choice: BallotChoice::Reject,
192                        sender: env.sender.clone(),
193                        reason: payload.reason,
194                    },
195                );
196                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
197            }
198            "Abstain" => {
199                let payload =
200                    AbstainPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
201                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
202                if payload.request_id != request.request_id
203                    || state.ballots.contains_key(&env.sender)
204                {
205                    return Err(MacpError::InvalidPayload);
206                }
207                state.ballots.insert(
208                    env.sender.clone(),
209                    BallotRecord {
210                        request_id: payload.request_id,
211                        choice: BallotChoice::Abstain,
212                        sender: env.sender.clone(),
213                        reason: payload.reason,
214                    },
215                );
216                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
217            }
218            "Commitment" => {
219                validate_commitment_payload_for_session(session, &env.payload)?;
220                if !Self::commitment_ready(session, &state) {
221                    return Err(MacpError::InvalidPayload);
222                }
223                // Evaluate governance policy if one is bound to the session.
224                if let Some(ref policy) = session.policy_definition {
225                    let approve_count = state
226                        .ballots
227                        .values()
228                        .filter(|b| b.choice == BallotChoice::Approve)
229                        .count();
230                    let reject_count = state
231                        .ballots
232                        .values()
233                        .filter(|b| b.choice == BallotChoice::Reject)
234                        .count();
235                    let abstain_count = state
236                        .ballots
237                        .values()
238                        .filter(|b| b.choice == BallotChoice::Abstain)
239                        .count();
240                    let decision = self.evaluator.evaluate_quorum_commitment(
241                        policy,
242                        approve_count,
243                        reject_count,
244                        abstain_count,
245                        session.participants.len(),
246                    );
247                    if let macp_core::policy::PolicyDecision::Deny { reasons } = decision {
248                        tracing::warn!(
249                            session_id = %session.session_id,
250                            policy_id = %policy.policy_id,
251                            reasons = ?reasons,
252                            "policy denied commitment"
253                        );
254                        return Err(MacpError::PolicyDenied { reasons });
255                    }
256                }
257                Ok(ModeResponse::PersistAndResolve {
258                    state: Self::encode_state(&state),
259                    resolution: env.payload.clone(),
260                })
261            }
262            _ => Err(MacpError::InvalidPayload),
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use macp_core::session::{Session, SessionState};
271    use macp_pb::pb::CommitmentPayload;
272    use std::collections::HashSet;
273
274    fn base_session() -> Session {
275        Session {
276            session_id: "s1".into(),
277            state: SessionState::Open,
278            ttl_expiry: i64::MAX,
279            ttl_ms: 60_000,
280            started_at_unix_ms: 0,
281            resolution: None,
282            mode: "macp.mode.quorum.v1".into(),
283            mode_state: vec![],
284            participants: vec!["alice".into(), "bob".into(), "carol".into()],
285            seen_message_ids: HashSet::new(),
286            intent: String::new(),
287            mode_version: "1.0.0".into(),
288            configuration_version: "config".into(),
289            policy_version: "policy".into(),
290            context_id: String::new(),
291            extensions: std::collections::HashMap::new(),
292            roots: vec![],
293            initiator_sender: "coordinator".into(),
294            participant_message_counts: std::collections::HashMap::new(),
295            participant_last_seen: std::collections::HashMap::new(),
296            policy_definition: None,
297            suspended_at_ms: None,
298            accumulated_suspended_ms: 0,
299        }
300    }
301
302    fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
303        Envelope {
304            macp_version: "1.0".into(),
305            mode: "macp.mode.quorum.v1".into(),
306            message_type: message_type.into(),
307            message_id: format!("{}-{}", sender, message_type),
308            session_id: "s1".into(),
309            sender: sender.into(),
310            timestamp_unix_ms: 0,
311            payload,
312        }
313    }
314
315    fn commitment_payload() -> Vec<u8> {
316        commitment("quorum.approved", true)
317    }
318
319    fn commitment(action: &str, outcome_positive: bool) -> Vec<u8> {
320        CommitmentPayload {
321            commitment_id: "c1".into(),
322            action: action.into(),
323            authority_scope: "deploy".into(),
324            reason: "threshold met".into(),
325            mode_version: "1.0.0".into(),
326            policy_version: "policy".into(),
327            configuration_version: "config".into(),
328            outcome_positive,
329            supersedes: None,
330        }
331        .encode_to_vec()
332    }
333
334    fn apply(session: &mut Session, result: ModeResponse) {
335        match result {
336            ModeResponse::PersistState(data) => session.mode_state = data,
337            ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
338            _ => {}
339        }
340    }
341
342    fn make_approval_request(request_id: &str, required: u32) -> Vec<u8> {
343        ApprovalRequestPayload {
344            request_id: request_id.into(),
345            action: "deploy.production".into(),
346            summary: "Deploy v2".into(),
347            details: vec![],
348            required_approvals: required,
349        }
350        .encode_to_vec()
351    }
352
353    fn make_approve(request_id: &str, reason: &str) -> Vec<u8> {
354        ApprovePayload {
355            request_id: request_id.into(),
356            reason: reason.into(),
357        }
358        .encode_to_vec()
359    }
360
361    fn make_reject(request_id: &str, reason: &str) -> Vec<u8> {
362        RejectPayload {
363            request_id: request_id.into(),
364            reason: reason.into(),
365        }
366        .encode_to_vec()
367    }
368
369    fn make_abstain(request_id: &str, reason: &str) -> Vec<u8> {
370        AbstainPayload {
371            request_id: request_id.into(),
372            reason: reason.into(),
373        }
374        .encode_to_vec()
375    }
376
377    // --- Session Start ---
378
379    #[test]
380    fn session_start_initializes_state() {
381        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
382        let session = base_session();
383        let result = mode
384            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
385            .unwrap();
386        match result {
387            ModeResponse::PersistState(data) => {
388                let state: QuorumState = serde_json::from_slice(&data).unwrap();
389                assert!(state.request.is_none());
390                assert!(state.ballots.is_empty());
391            }
392            _ => panic!("Expected PersistState"),
393        }
394    }
395
396    #[test]
397    fn session_start_requires_participants() {
398        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
399        let mut session = base_session();
400        session.participants.clear();
401        let err = mode
402            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
403            .unwrap_err();
404        assert_eq!(err.to_string(), "InvalidPayload");
405    }
406
407    // --- ApprovalRequest ---
408
409    #[test]
410    fn approval_request_from_coordinator() {
411        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
412        let mut session = base_session();
413        let result = mode
414            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
415            .unwrap();
416        apply(&mut session, result);
417        let result = mode
418            .on_message(
419                &session,
420                &env(
421                    "coordinator",
422                    "ApprovalRequest",
423                    make_approval_request("r1", 2),
424                ),
425            )
426            .unwrap();
427        match result {
428            ModeResponse::PersistState(data) => {
429                let state: QuorumState = serde_json::from_slice(&data).unwrap();
430                assert!(state.request.is_some());
431                assert_eq!(state.request.unwrap().required_approvals, 2);
432            }
433            _ => panic!("Expected PersistState"),
434        }
435    }
436
437    #[test]
438    fn duplicate_approval_request_rejected() {
439        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
440        let mut session = base_session();
441        let result = mode
442            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
443            .unwrap();
444        apply(&mut session, result);
445        let result = mode
446            .on_message(
447                &session,
448                &env(
449                    "coordinator",
450                    "ApprovalRequest",
451                    make_approval_request("r1", 2),
452                ),
453            )
454            .unwrap();
455        apply(&mut session, result);
456        let err = mode
457            .on_message(
458                &session,
459                &env(
460                    "coordinator",
461                    "ApprovalRequest",
462                    make_approval_request("r2", 1),
463                ),
464            )
465            .unwrap_err();
466        assert_eq!(err.to_string(), "InvalidPayload");
467    }
468
469    #[test]
470    fn required_approvals_exceeds_participants_rejected() {
471        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
472        let mut session = base_session();
473        let result = mode
474            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
475            .unwrap();
476        apply(&mut session, result);
477        let err = mode
478            .on_message(
479                &session,
480                &env(
481                    "coordinator",
482                    "ApprovalRequest",
483                    make_approval_request("r1", 4),
484                ),
485            )
486            .unwrap_err();
487        assert_eq!(err.to_string(), "InvalidPayload");
488    }
489
490    #[test]
491    fn required_approvals_zero_rejected() {
492        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
493        let mut session = base_session();
494        let result = mode
495            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
496            .unwrap();
497        apply(&mut session, result);
498        let err = mode
499            .on_message(
500                &session,
501                &env(
502                    "coordinator",
503                    "ApprovalRequest",
504                    make_approval_request("r1", 0),
505                ),
506            )
507            .unwrap_err();
508        assert_eq!(err.to_string(), "InvalidPayload");
509    }
510
511    #[test]
512    fn non_coordinator_approval_request_rejected() {
513        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
514        let mut session = base_session();
515        let result = mode
516            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
517            .unwrap();
518        apply(&mut session, result);
519        let err = mode
520            .on_message(
521                &session,
522                &env("alice", "ApprovalRequest", make_approval_request("r1", 2)),
523            )
524            .unwrap_err();
525        assert_eq!(err.to_string(), "Forbidden");
526    }
527
528    // --- Ballots ---
529
530    #[test]
531    fn participant_can_approve() {
532        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
533        let mut session = base_session();
534        let result = mode
535            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
536            .unwrap();
537        apply(&mut session, result);
538        let result = mode
539            .on_message(
540                &session,
541                &env(
542                    "coordinator",
543                    "ApprovalRequest",
544                    make_approval_request("r1", 2),
545                ),
546            )
547            .unwrap();
548        apply(&mut session, result);
549        let result = mode
550            .on_message(
551                &session,
552                &env("alice", "Approve", make_approve("r1", "looks good")),
553            )
554            .unwrap();
555        match result {
556            ModeResponse::PersistState(data) => {
557                let state: QuorumState = serde_json::from_slice(&data).unwrap();
558                assert!(state.ballots.contains_key("alice"));
559                assert_eq!(state.ballots["alice"].choice, BallotChoice::Approve);
560            }
561            _ => panic!("Expected PersistState"),
562        }
563    }
564
565    #[test]
566    fn participant_can_reject() {
567        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
568        let mut session = base_session();
569        let result = mode
570            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
571            .unwrap();
572        apply(&mut session, result);
573        let result = mode
574            .on_message(
575                &session,
576                &env(
577                    "coordinator",
578                    "ApprovalRequest",
579                    make_approval_request("r1", 2),
580                ),
581            )
582            .unwrap();
583        apply(&mut session, result);
584        let result = mode
585            .on_message(
586                &session,
587                &env("alice", "Reject", make_reject("r1", "not ready")),
588            )
589            .unwrap();
590        match result {
591            ModeResponse::PersistState(data) => {
592                let state: QuorumState = serde_json::from_slice(&data).unwrap();
593                assert_eq!(state.ballots["alice"].choice, BallotChoice::Reject);
594            }
595            _ => panic!("Expected PersistState"),
596        }
597    }
598
599    #[test]
600    fn participant_can_abstain() {
601        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
602        let mut session = base_session();
603        let result = mode
604            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
605            .unwrap();
606        apply(&mut session, result);
607        let result = mode
608            .on_message(
609                &session,
610                &env(
611                    "coordinator",
612                    "ApprovalRequest",
613                    make_approval_request("r1", 2),
614                ),
615            )
616            .unwrap();
617        apply(&mut session, result);
618        let result = mode
619            .on_message(
620                &session,
621                &env("alice", "Abstain", make_abstain("r1", "no opinion")),
622            )
623            .unwrap();
624        match result {
625            ModeResponse::PersistState(data) => {
626                let state: QuorumState = serde_json::from_slice(&data).unwrap();
627                assert_eq!(state.ballots["alice"].choice, BallotChoice::Abstain);
628            }
629            _ => panic!("Expected PersistState"),
630        }
631    }
632
633    #[test]
634    fn duplicate_ballot_rejected() {
635        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
636        let mut session = base_session();
637        let result = mode
638            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
639            .unwrap();
640        apply(&mut session, result);
641        let result = mode
642            .on_message(
643                &session,
644                &env(
645                    "coordinator",
646                    "ApprovalRequest",
647                    make_approval_request("r1", 2),
648                ),
649            )
650            .unwrap();
651        apply(&mut session, result);
652        let result = mode
653            .on_message(
654                &session,
655                &env("alice", "Approve", make_approve("r1", "yes")),
656            )
657            .unwrap();
658        apply(&mut session, result);
659        let err = mode
660            .on_message(
661                &session,
662                &env("alice", "Approve", make_approve("r1", "again")),
663            )
664            .unwrap_err();
665        assert_eq!(err.to_string(), "InvalidPayload");
666    }
667
668    #[test]
669    fn ballot_before_request_rejected() {
670        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
671        let mut session = base_session();
672        let result = mode
673            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
674            .unwrap();
675        apply(&mut session, result);
676        let err = mode
677            .on_message(
678                &session,
679                &env("alice", "Approve", make_approve("r1", "premature")),
680            )
681            .unwrap_err();
682        assert_eq!(err.to_string(), "InvalidPayload");
683    }
684
685    #[test]
686    fn wrong_request_id_rejected() {
687        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
688        let mut session = base_session();
689        let result = mode
690            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
691            .unwrap();
692        apply(&mut session, result);
693        let result = mode
694            .on_message(
695                &session,
696                &env(
697                    "coordinator",
698                    "ApprovalRequest",
699                    make_approval_request("r1", 2),
700                ),
701            )
702            .unwrap();
703        apply(&mut session, result);
704        let err = mode
705            .on_message(
706                &session,
707                &env("alice", "Approve", make_approve("r2", "wrong id")),
708            )
709            .unwrap_err();
710        assert_eq!(err.to_string(), "InvalidPayload");
711    }
712
713    // --- Commitment ---
714
715    #[test]
716    fn commitment_when_threshold_reached() {
717        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
718        let mut session = base_session();
719        let result = mode
720            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
721            .unwrap();
722        apply(&mut session, result);
723        let result = mode
724            .on_message(
725                &session,
726                &env(
727                    "coordinator",
728                    "ApprovalRequest",
729                    make_approval_request("r1", 2),
730                ),
731            )
732            .unwrap();
733        apply(&mut session, result);
734        let result = mode
735            .on_message(
736                &session,
737                &env("alice", "Approve", make_approve("r1", "yes")),
738            )
739            .unwrap();
740        apply(&mut session, result);
741        let result = mode
742            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
743            .unwrap();
744        apply(&mut session, result);
745        let result = mode
746            .on_message(
747                &session,
748                &env("coordinator", "Commitment", commitment_payload()),
749            )
750            .unwrap();
751        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
752    }
753
754    #[test]
755    fn commitment_when_threshold_unreachable() {
756        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
757        let mut session = base_session();
758        let result = mode
759            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
760            .unwrap();
761        apply(&mut session, result);
762        let result = mode
763            .on_message(
764                &session,
765                &env(
766                    "coordinator",
767                    "ApprovalRequest",
768                    make_approval_request("r1", 3),
769                ),
770            )
771            .unwrap();
772        apply(&mut session, result);
773        // All 3 must approve for threshold=3, but alice rejects
774        let result = mode
775            .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
776            .unwrap();
777        apply(&mut session, result);
778        // Threshold is now unreachable (need 3, max possible = 2)
779        let result = mode
780            .on_message(
781                &session,
782                &env("coordinator", "Commitment", commitment_payload()),
783            )
784            .unwrap();
785        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
786    }
787
788    #[test]
789    fn commitment_before_threshold_rejected() {
790        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
791        let mut session = base_session();
792        let result = mode
793            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
794            .unwrap();
795        apply(&mut session, result);
796        let result = mode
797            .on_message(
798                &session,
799                &env(
800                    "coordinator",
801                    "ApprovalRequest",
802                    make_approval_request("r1", 2),
803                ),
804            )
805            .unwrap();
806        apply(&mut session, result);
807        let result = mode
808            .on_message(
809                &session,
810                &env("alice", "Approve", make_approve("r1", "yes")),
811            )
812            .unwrap();
813        apply(&mut session, result);
814        // Only 1 approval, threshold is 2, and 2 participants left can still vote
815        let err = mode
816            .on_message(
817                &session,
818                &env("coordinator", "Commitment", commitment_payload()),
819            )
820            .unwrap_err();
821        assert_eq!(err.to_string(), "InvalidPayload");
822    }
823
824    #[test]
825    fn non_coordinator_commitment_rejected() {
826        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
827        let mut session = base_session();
828        let result = mode
829            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
830            .unwrap();
831        apply(&mut session, result);
832        let result = mode
833            .on_message(
834                &session,
835                &env(
836                    "coordinator",
837                    "ApprovalRequest",
838                    make_approval_request("r1", 2),
839                ),
840            )
841            .unwrap();
842        apply(&mut session, result);
843        let result = mode
844            .on_message(
845                &session,
846                &env("alice", "Approve", make_approve("r1", "yes")),
847            )
848            .unwrap();
849        apply(&mut session, result);
850        let result = mode
851            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
852            .unwrap();
853        apply(&mut session, result);
854        let commit_env = env("alice", "Commitment", commitment_payload());
855        let err = mode.authorize_sender(&session, &commit_env).unwrap_err();
856        assert_eq!(err.to_string(), "Forbidden");
857    }
858
859    // --- Full lifecycle ---
860
861    #[test]
862    fn full_quorum_approve_lifecycle() {
863        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
864        let mut session = base_session();
865        let result = mode
866            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
867            .unwrap();
868        apply(&mut session, result);
869        let result = mode
870            .on_message(
871                &session,
872                &env(
873                    "coordinator",
874                    "ApprovalRequest",
875                    make_approval_request("r1", 2),
876                ),
877            )
878            .unwrap();
879        apply(&mut session, result);
880        let result = mode
881            .on_message(
882                &session,
883                &env("alice", "Approve", make_approve("r1", "green")),
884            )
885            .unwrap();
886        apply(&mut session, result);
887        let result = mode
888            .on_message(
889                &session,
890                &env("bob", "Approve", make_approve("r1", "ready")),
891            )
892            .unwrap();
893        apply(&mut session, result);
894        let result = mode
895            .on_message(
896                &session,
897                &env("coordinator", "Commitment", commitment_payload()),
898            )
899            .unwrap();
900        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
901    }
902
903    #[test]
904    fn full_quorum_reject_lifecycle() {
905        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
906        let mut session = base_session();
907        let result = mode
908            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
909            .unwrap();
910        apply(&mut session, result);
911        let result = mode
912            .on_message(
913                &session,
914                &env(
915                    "coordinator",
916                    "ApprovalRequest",
917                    make_approval_request("r1", 3),
918                ),
919            )
920            .unwrap();
921        apply(&mut session, result);
922        let result = mode
923            .on_message(
924                &session,
925                &env("alice", "Reject", make_reject("r1", "not ready")),
926            )
927            .unwrap();
928        apply(&mut session, result);
929        // Threshold unreachable: need 3, 1 rejected, only 2 left, max possible = 2
930        let result = mode
931            .on_message(
932                &session,
933                &env("coordinator", "Commitment", commitment_payload()),
934            )
935            .unwrap();
936        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
937    }
938
939    // --- Commitment version mismatch ---
940
941    #[test]
942    fn commitment_version_mismatch_rejected() {
943        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
944        let mut session = base_session();
945        let result = mode
946            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
947            .unwrap();
948        apply(&mut session, result);
949        let result = mode
950            .on_message(
951                &session,
952                &env(
953                    "coordinator",
954                    "ApprovalRequest",
955                    make_approval_request("r1", 2),
956                ),
957            )
958            .unwrap();
959        apply(&mut session, result);
960        let result = mode
961            .on_message(
962                &session,
963                &env("alice", "Approve", make_approve("r1", "yes")),
964            )
965            .unwrap();
966        apply(&mut session, result);
967        let result = mode
968            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
969            .unwrap();
970        apply(&mut session, result);
971        let bad_commitment = CommitmentPayload {
972            commitment_id: "c1".into(),
973            action: "quorum.approved".into(),
974            authority_scope: "deploy".into(),
975            reason: "threshold met".into(),
976            mode_version: "wrong".into(),
977            policy_version: "policy".into(),
978            configuration_version: "config".into(),
979            outcome_positive: true,
980            supersedes: None,
981        }
982        .encode_to_vec();
983        let err = mode
984            .on_message(&session, &env("coordinator", "Commitment", bad_commitment))
985            .unwrap_err();
986        assert_eq!(err.to_string(), "InvalidPayload");
987    }
988
989    // --- Unknown message type ---
990
991    #[test]
992    fn unknown_message_type_rejected() {
993        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
994        let mut session = base_session();
995        let result = mode
996            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
997            .unwrap();
998        apply(&mut session, result);
999        let err = mode
1000            .on_message(&session, &env("alice", "CustomType", vec![]))
1001            .unwrap_err();
1002        assert_eq!(err.to_string(), "InvalidPayload");
1003    }
1004
1005    // --- Policy ---
1006
1007    #[test]
1008    fn policy_denies_commitment_when_quorum_not_met_due_to_abstentions() {
1009        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1010        let mut session = base_session();
1011        // Require all 3 participants as voters, but abstentions don't count toward quorum
1012        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1013            policy_id: "test-strict-quorum".into(),
1014            mode: "macp.mode.quorum.v1".into(),
1015            description: "strict quorum".into(),
1016            rules: serde_json::json!({
1017                "threshold": { "type": "n_of_m", "value": 3 },
1018                "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" }
1019            }),
1020            schema_version: 1,
1021        });
1022        let result = mode
1023            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1024            .unwrap();
1025        apply(&mut session, result);
1026        let result = mode
1027            .on_message(
1028                &session,
1029                &env(
1030                    "coordinator",
1031                    "ApprovalRequest",
1032                    make_approval_request("r1", 2),
1033                ),
1034            )
1035            .unwrap();
1036        apply(&mut session, result);
1037        let result = mode
1038            .on_message(
1039                &session,
1040                &env("alice", "Approve", make_approve("r1", "yes")),
1041            )
1042            .unwrap();
1043        apply(&mut session, result);
1044        let result = mode
1045            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1046            .unwrap();
1047        apply(&mut session, result);
1048        // carol abstains — with counts_toward_quorum=false, effective voters = 2 < 3
1049        let result = mode
1050            .on_message(
1051                &session,
1052                &env("carol", "Abstain", make_abstain("r1", "no opinion")),
1053            )
1054            .unwrap();
1055        apply(&mut session, result);
1056        // Commitment should be denied (quorum not met: 2 effective voters < 3 required)
1057        let err = mode
1058            .on_message(
1059                &session,
1060                &env("coordinator", "Commitment", commitment_payload()),
1061            )
1062            .unwrap_err();
1063        assert_eq!(err.to_string(), "PolicyDenied");
1064    }
1065
1066    // --- Negative outcome commitment ---
1067
1068    #[test]
1069    fn negative_outcome_quorum_rejected() {
1070        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1071        let mut session = base_session();
1072        // 3 participants, required_approvals = 2
1073        let result = mode
1074            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1075            .unwrap();
1076        apply(&mut session, result);
1077        let result = mode
1078            .on_message(
1079                &session,
1080                &env(
1081                    "coordinator",
1082                    "ApprovalRequest",
1083                    make_approval_request("r1", 2),
1084                ),
1085            )
1086            .unwrap();
1087        apply(&mut session, result);
1088        // Two participants reject, making the threshold (2 approvals) unreachable
1089        let result = mode
1090            .on_message(
1091                &session,
1092                &env("alice", "Reject", make_reject("r1", "not ready")),
1093            )
1094            .unwrap();
1095        apply(&mut session, result);
1096        let result = mode
1097            .on_message(
1098                &session,
1099                &env("bob", "Reject", make_reject("r1", "disagree")),
1100            )
1101            .unwrap();
1102        apply(&mut session, result);
1103        // Threshold is unreachable: 0 approvals + 1 remaining < 2 required
1104        // Commit with negative outcome
1105        let negative_commitment = commitment("quorum.rejected", false);
1106        let result = mode
1107            .on_message(
1108                &session,
1109                &env("coordinator", "Commitment", negative_commitment),
1110            )
1111            .unwrap();
1112        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1113    }
1114
1115    // --- All participants abstain — eligible for negative commitment ---
1116
1117    #[test]
1118    fn all_participants_abstain_allows_negative_commitment() {
1119        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1120        let mut session = base_session();
1121        // 3 participants, required_approvals = 2
1122        let result = mode
1123            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1124            .unwrap();
1125        apply(&mut session, result);
1126        let result = mode
1127            .on_message(
1128                &session,
1129                &env(
1130                    "coordinator",
1131                    "ApprovalRequest",
1132                    make_approval_request("r1", 2),
1133                ),
1134            )
1135            .unwrap();
1136        apply(&mut session, result);
1137        // All 3 participants abstain
1138        let result = mode
1139            .on_message(
1140                &session,
1141                &env("alice", "Abstain", make_abstain("r1", "neutral")),
1142            )
1143            .unwrap();
1144        apply(&mut session, result);
1145        let result = mode
1146            .on_message(
1147                &session,
1148                &env("bob", "Abstain", make_abstain("r1", "neutral")),
1149            )
1150            .unwrap();
1151        apply(&mut session, result);
1152        let result = mode
1153            .on_message(
1154                &session,
1155                &env("carol", "Abstain", make_abstain("r1", "neutral")),
1156            )
1157            .unwrap();
1158        apply(&mut session, result);
1159        // Threshold is unreachable: 0 approvals + 0 remaining < 2 required
1160        // commitment_ready() returns true, so a negative commitment should succeed
1161        let negative_commitment = CommitmentPayload {
1162            commitment_id: "c1".into(),
1163            action: "quorum.rejected".into(),
1164            authority_scope: "deploy".into(),
1165            reason: "all abstained".into(),
1166            mode_version: "1.0.0".into(),
1167            policy_version: "policy".into(),
1168            configuration_version: "config".into(),
1169            outcome_positive: false,
1170            supersedes: None,
1171        }
1172        .encode_to_vec();
1173        let result = mode
1174            .on_message(
1175                &session,
1176                &env("coordinator", "Commitment", negative_commitment),
1177            )
1178            .unwrap();
1179        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1180    }
1181
1182    // --- Initiator not in participants cannot cast ballot ---
1183
1184    #[test]
1185    fn initiator_not_in_participants_cannot_cast_ballot() {
1186        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1187        let mut session = base_session();
1188        // coordinator is initiator but NOT in participants
1189        // participants are alice, bob, carol (coordinator excluded)
1190        let result = mode
1191            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1192            .unwrap();
1193        apply(&mut session, result);
1194        let result = mode
1195            .on_message(
1196                &session,
1197                &env(
1198                    "coordinator",
1199                    "ApprovalRequest",
1200                    make_approval_request("r1", 2),
1201                ),
1202            )
1203            .unwrap();
1204        apply(&mut session, result);
1205        // coordinator tries to Approve — should be Forbidden because coordinator
1206        // is not a declared participant
1207        let approve_env = env("coordinator", "Approve", make_approve("r1", "yes"));
1208        let err = mode.authorize_sender(&session, &approve_env).unwrap_err();
1209        assert_eq!(err.to_string(), "Forbidden");
1210    }
1211
1212    // ── Quorum policy threshold override (RFC-MACP-0011) ───────────
1213
1214    #[test]
1215    fn policy_threshold_overrides_required_approvals() {
1216        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1217        let mut session = base_session();
1218        // Policy sets n_of_m threshold to 1, while ApprovalRequest requires 3
1219        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1220            policy_id: "threshold-override".into(),
1221            mode: "macp.mode.quorum.v1".into(),
1222            description: "low threshold".into(),
1223            rules: serde_json::json!({
1224                "threshold": { "type": "n_of_m", "value": 1.0 }
1225            }),
1226            schema_version: 1,
1227        });
1228        let result = mode
1229            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1230            .unwrap();
1231        apply(&mut session, result);
1232        let result = mode
1233            .on_message(
1234                &session,
1235                &env(
1236                    "coordinator",
1237                    "ApprovalRequest",
1238                    make_approval_request("r1", 3), // requires 3, but policy overrides to 1
1239                ),
1240            )
1241            .unwrap();
1242        apply(&mut session, result);
1243        // Just 1 approval should make commitment ready (policy overrides to 1)
1244        let result = mode
1245            .on_message(
1246                &session,
1247                &env("alice", "Approve", make_approve("r1", "yes")),
1248            )
1249            .unwrap();
1250        apply(&mut session, result);
1251        // Commitment should succeed
1252        let commit = mode
1253            .on_message(
1254                &session,
1255                &env("coordinator", "Commitment", commitment_payload()),
1256            )
1257            .unwrap();
1258        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1259    }
1260
1261    #[test]
1262    fn policy_percentage_threshold() {
1263        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1264        let mut session = base_session();
1265        // 3 participants, 50% → ceil(1.5) = 2 required
1266        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1267            policy_id: "pct-override".into(),
1268            mode: "macp.mode.quorum.v1".into(),
1269            description: "percentage threshold".into(),
1270            rules: serde_json::json!({
1271                "threshold": { "type": "percentage", "value": 50.0 }
1272            }),
1273            schema_version: 1,
1274        });
1275        let result = mode
1276            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1277            .unwrap();
1278        apply(&mut session, result);
1279        let result = mode
1280            .on_message(
1281                &session,
1282                &env(
1283                    "coordinator",
1284                    "ApprovalRequest",
1285                    make_approval_request("r1", 3),
1286                ),
1287            )
1288            .unwrap();
1289        apply(&mut session, result);
1290        // 1 approval is not enough (need 2 for 50% of 3)
1291        let result = mode
1292            .on_message(
1293                &session,
1294                &env("alice", "Approve", make_approve("r1", "yes")),
1295            )
1296            .unwrap();
1297        apply(&mut session, result);
1298        let err = mode
1299            .on_message(
1300                &session,
1301                &env("coordinator", "Commitment", commitment_payload()),
1302            )
1303            .unwrap_err();
1304        assert_eq!(err.to_string(), "InvalidPayload");
1305        // 2nd approval makes it ready
1306        let result = mode
1307            .on_message(
1308                &session,
1309                &env("bob", "Approve", make_approve("r1", "agreed")),
1310            )
1311            .unwrap();
1312        apply(&mut session, result);
1313        let commit = mode
1314            .on_message(
1315                &session,
1316                &env("coordinator", "Commitment", commitment_payload()),
1317            )
1318            .unwrap();
1319        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1320    }
1321
1322    #[test]
1323    fn all_abstain_eligible_for_negative_commitment() {
1324        // RFC-MACP-0011: "When all eligible participants have abstained, the Session
1325        // becomes eligible for Commitment with a negative outcome."
1326        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1327        let mut session = base_session();
1328        let result = mode
1329            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1330            .unwrap();
1331        apply(&mut session, result);
1332        let result = mode
1333            .on_message(
1334                &session,
1335                &env(
1336                    "coordinator",
1337                    "ApprovalRequest",
1338                    make_approval_request("r1", 2),
1339                ),
1340            )
1341            .unwrap();
1342        apply(&mut session, result);
1343        // All 3 participants abstain
1344        for sender in &["alice", "bob", "carol"] {
1345            let result = mode
1346                .on_message(
1347                    &session,
1348                    &env(sender, "Abstain", make_abstain("r1", "neutral")),
1349                )
1350                .unwrap();
1351            apply(&mut session, result);
1352        }
1353        // Commitment should be ready (0 approvals + 0 remaining < 2 required)
1354        let commit = mode
1355            .on_message(
1356                &session,
1357                &env(
1358                    "coordinator",
1359                    "Commitment",
1360                    commitment("quorum.rejected", false),
1361                ),
1362            )
1363            .unwrap();
1364        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1365    }
1366}