Skip to main content

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