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
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum ApprovalThreshold {
72 Approvals(u32),
79 Unsatisfiable,
92}
93
94pub struct QuorumMode {
95 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
96}
97
98impl QuorumMode {
99 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
101 Self { evaluator }
102 }
103
104 fn encode_state(state: &QuorumState) -> Vec<u8> {
105 crate::mode::util::encode_mode_state(state)
106 }
107
108 fn decode_state(data: &[u8]) -> Result<QuorumState, MacpError> {
109 crate::mode::util::decode_mode_state(data)
110 }
111
112 pub fn effective_threshold(
140 session: &Session,
141 request: &ApprovalRequestRecord,
142 ) -> ApprovalThreshold {
143 let Some(ref policy) = session.policy_definition else {
144 return ApprovalThreshold::Approvals(request.required_approvals);
145 };
146 let rules: macp_core::policy::rules::QuorumPolicyRules =
147 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
148 match rules.threshold.effective(session.participants.len()) {
149 macp_core::policy::rules::EffectiveThreshold::Inert => {
150 ApprovalThreshold::Approvals(request.required_approvals)
151 }
152 macp_core::policy::rules::EffectiveThreshold::Approvals(required) => {
153 ApprovalThreshold::Approvals(required)
154 }
155 macp_core::policy::rules::EffectiveThreshold::Unsatisfiable => {
156 ApprovalThreshold::Unsatisfiable
157 }
158 }
159 }
160
161 pub fn effective_threshold_for_session(
206 session: &Session,
207 ) -> Result<Option<ApprovalThreshold>, MacpError> {
208 if session.mode_state.is_empty() {
209 return Ok(None);
210 }
211 let state = Self::decode_state(&session.mode_state)?;
212 Ok(state
213 .request
214 .as_ref()
215 .map(|request| Self::effective_threshold(session, request)))
216 }
217
218 fn commitment_ready(session: &Session, state: &QuorumState) -> bool {
219 let request = match &state.request {
220 Some(request) => request,
221 None => return false,
222 };
223 let ApprovalThreshold::Approvals(required) = Self::effective_threshold(session, request)
224 else {
225 return false;
229 };
230 let approvals = state
231 .ballots
232 .values()
233 .filter(|ballot| ballot.choice == BallotChoice::Approve)
234 .count() as u32;
235 let total_eligible = session.participants.len() as u32;
236 let counted = state.ballots.len() as u32;
237 let remaining = total_eligible.saturating_sub(counted);
238 approvals >= required || (counted > 0 && approvals + remaining < required)
254 }
255}
256
257impl Mode for QuorumMode {
258 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
259 match env.message_type.as_str() {
260 "ApprovalRequest" if env.sender == session.initiator_sender => Ok(()),
261 "ApprovalRequest" => Err(MacpError::Forbidden),
262 "Commitment" => check_commitment_authority(session, &env.sender),
263 _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
264 _ => Err(MacpError::Forbidden),
265 }
266 }
267
268 fn on_session_start(
269 &self,
270 session: &Session,
271 _env: &Envelope,
272 ) -> Result<ModeResponse, MacpError> {
273 if session.participants.is_empty() {
274 return Err(MacpError::InvalidPayload);
275 }
276 Ok(ModeResponse::PersistState(Self::encode_state(
277 &QuorumState::default(),
278 )))
279 }
280
281 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
282 let mut state = if session.mode_state.is_empty() {
283 QuorumState::default()
284 } else {
285 Self::decode_state(&session.mode_state)?
286 };
287
288 match env.message_type.as_str() {
289 "ApprovalRequest" => {
290 if env.sender != session.initiator_sender {
291 return Err(MacpError::Forbidden);
292 }
293 let payload = ApprovalRequestPayload::decode(&*env.payload)
294 .map_err(|_| MacpError::InvalidPayload)?;
295 if state.request.is_some()
296 || payload.request_id.is_empty()
297 || payload.required_approvals == 0
298 || payload.required_approvals > session.participants.len() as u32
299 {
300 return Err(MacpError::InvalidPayload);
301 }
302 let record = ApprovalRequestRecord {
303 request_id: payload.request_id,
304 action: payload.action,
305 summary: payload.summary,
306 details: payload.details,
307 required_approvals: payload.required_approvals,
308 requested_by: env.sender.clone(),
309 };
310 match Self::effective_threshold(session, &record) {
321 ApprovalThreshold::Approvals(required)
322 if required >= 1 && required <= session.participants.len() as u32 => {}
323 other => {
324 tracing::warn!(
325 session_id = %session.session_id,
326 policy_id = session
327 .policy_definition
328 .as_ref()
329 .map(|p| p.policy_id.as_str())
330 .unwrap_or(""),
331 effective_threshold = ?other,
332 participants = session.participants.len(),
333 "quorum policy threshold is outside 1..=participants; \
334 refusing the ApprovalRequest"
335 );
336 return Err(MacpError::InvalidPayload);
337 }
338 }
339 state.request = Some(record);
340 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
341 }
342 "Approve" => {
343 let payload =
344 ApprovePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
345 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
346 if payload.request_id != request.request_id
347 || state.ballots.contains_key(&env.sender)
348 {
349 return Err(MacpError::InvalidPayload);
350 }
351 state.ballots.insert(
352 env.sender.clone(),
353 BallotRecord {
354 request_id: payload.request_id,
355 choice: BallotChoice::Approve,
356 sender: env.sender.clone(),
357 reason: payload.reason,
358 },
359 );
360 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
361 }
362 "Reject" => {
363 let payload =
364 RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
365 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
366 if payload.request_id != request.request_id
367 || state.ballots.contains_key(&env.sender)
368 {
369 return Err(MacpError::InvalidPayload);
370 }
371 state.ballots.insert(
372 env.sender.clone(),
373 BallotRecord {
374 request_id: payload.request_id,
375 choice: BallotChoice::Reject,
376 sender: env.sender.clone(),
377 reason: payload.reason,
378 },
379 );
380 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
381 }
382 "Abstain" => {
383 let payload =
384 AbstainPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
385 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
386 if payload.request_id != request.request_id
387 || state.ballots.contains_key(&env.sender)
388 {
389 return Err(MacpError::InvalidPayload);
390 }
391 state.ballots.insert(
392 env.sender.clone(),
393 BallotRecord {
394 request_id: payload.request_id,
395 choice: BallotChoice::Abstain,
396 sender: env.sender.clone(),
397 reason: payload.reason,
398 },
399 );
400 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
401 }
402 "Commitment" => {
403 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
404 if !Self::commitment_ready(session, &state) {
405 return Err(MacpError::InvalidPayload);
406 }
407 let approve_count = state
410 .ballots
411 .values()
412 .filter(|b| b.choice == BallotChoice::Approve)
413 .count();
414 let reject_count = state
415 .ballots
416 .values()
417 .filter(|b| b.choice == BallotChoice::Reject)
418 .count();
419 let abstain_count = state
420 .ballots
421 .values()
422 .filter(|b| b.choice == BallotChoice::Abstain)
423 .count();
424 enforce_commitment_policy(
425 session,
426 macp_core::policy::CommitmentMode::Quorum {
427 approve_count,
428 reject_count,
429 abstain_count,
430 },
431 commitment.outcome_positive,
432 &*self.evaluator,
433 )?;
434 Ok(ModeResponse::PersistAndResolve {
435 state: Self::encode_state(&state),
436 resolution: env.payload.clone(),
437 })
438 }
439 _ => Err(MacpError::InvalidPayload),
440 }
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use macp_core::session::Session;
448 use macp_pb::pb::CommitmentPayload;
449
450 fn base_session() -> Session {
451 Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
452 .ttl_ms(60_000)
453 .participants(vec!["alice".into(), "bob".into(), "carol".into()])
454 .mode_version("1.0.0")
455 .configuration_version("config")
456 .policy_version("policy")
457 .build()
458 }
459
460 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
461 Envelope {
462 macp_version: "1.0".into(),
463 mode: "macp.mode.quorum.v1".into(),
464 message_type: message_type.into(),
465 message_id: format!("{}-{}", sender, message_type),
466 session_id: "s1".into(),
467 sender: sender.into(),
468 timestamp_unix_ms: 0,
469 payload,
470 }
471 }
472
473 fn commitment_payload() -> Vec<u8> {
474 commitment("quorum.approved", true)
475 }
476
477 fn commitment(action: &str, outcome_positive: bool) -> Vec<u8> {
478 CommitmentPayload {
479 commitment_id: "c1".into(),
480 action: action.into(),
481 authority_scope: "deploy".into(),
482 reason: "threshold met".into(),
483 mode_version: "1.0.0".into(),
484 policy_version: "policy".into(),
485 configuration_version: "config".into(),
486 outcome_positive,
487 supersedes: None,
488 }
489 .encode_to_vec()
490 }
491
492 fn apply(session: &mut Session, result: ModeResponse) {
493 match result {
494 ModeResponse::PersistState(data) => session.mode_state = data,
495 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
496 _ => {}
497 }
498 }
499
500 fn make_approval_request(request_id: &str, required: u32) -> Vec<u8> {
501 ApprovalRequestPayload {
502 request_id: request_id.into(),
503 action: "deploy.production".into(),
504 summary: "Deploy v2".into(),
505 details: vec![],
506 required_approvals: required,
507 }
508 .encode_to_vec()
509 }
510
511 fn make_approve(request_id: &str, reason: &str) -> Vec<u8> {
512 ApprovePayload {
513 request_id: request_id.into(),
514 reason: reason.into(),
515 }
516 .encode_to_vec()
517 }
518
519 fn make_reject(request_id: &str, reason: &str) -> Vec<u8> {
520 RejectPayload {
521 request_id: request_id.into(),
522 reason: reason.into(),
523 }
524 .encode_to_vec()
525 }
526
527 fn make_abstain(request_id: &str, reason: &str) -> Vec<u8> {
528 AbstainPayload {
529 request_id: request_id.into(),
530 reason: reason.into(),
531 }
532 .encode_to_vec()
533 }
534
535 #[test]
538 fn session_start_initializes_state() {
539 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
540 let session = base_session();
541 let result = mode
542 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
543 .unwrap();
544 match result {
545 ModeResponse::PersistState(data) => {
546 let state: QuorumState = serde_json::from_slice(&data).unwrap();
547 assert!(state.request.is_none());
548 assert!(state.ballots.is_empty());
549 }
550 _ => panic!("Expected PersistState"),
551 }
552 }
553
554 #[test]
555 fn session_start_requires_participants() {
556 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
557 let mut session = base_session();
558 session.participants.clear();
559 let err = mode
560 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
561 .unwrap_err();
562 assert_eq!(err.to_string(), "InvalidPayload");
563 }
564
565 #[test]
568 fn approval_request_from_coordinator() {
569 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
570 let mut session = base_session();
571 let result = mode
572 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
573 .unwrap();
574 apply(&mut session, result);
575 let result = mode
576 .on_message(
577 &session,
578 &env(
579 "coordinator",
580 "ApprovalRequest",
581 make_approval_request("r1", 2),
582 ),
583 )
584 .unwrap();
585 match result {
586 ModeResponse::PersistState(data) => {
587 let state: QuorumState = serde_json::from_slice(&data).unwrap();
588 assert!(state.request.is_some());
589 assert_eq!(state.request.unwrap().required_approvals, 2);
590 }
591 _ => panic!("Expected PersistState"),
592 }
593 }
594
595 #[test]
596 fn duplicate_approval_request_rejected() {
597 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
598 let mut session = base_session();
599 let result = mode
600 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
601 .unwrap();
602 apply(&mut session, result);
603 let result = mode
604 .on_message(
605 &session,
606 &env(
607 "coordinator",
608 "ApprovalRequest",
609 make_approval_request("r1", 2),
610 ),
611 )
612 .unwrap();
613 apply(&mut session, result);
614 let err = mode
615 .on_message(
616 &session,
617 &env(
618 "coordinator",
619 "ApprovalRequest",
620 make_approval_request("r2", 1),
621 ),
622 )
623 .unwrap_err();
624 assert_eq!(err.to_string(), "InvalidPayload");
625 }
626
627 #[test]
628 fn required_approvals_exceeds_participants_rejected() {
629 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
630 let mut session = base_session();
631 let result = mode
632 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
633 .unwrap();
634 apply(&mut session, result);
635 let err = mode
636 .on_message(
637 &session,
638 &env(
639 "coordinator",
640 "ApprovalRequest",
641 make_approval_request("r1", 4),
642 ),
643 )
644 .unwrap_err();
645 assert_eq!(err.to_string(), "InvalidPayload");
646 }
647
648 #[test]
649 fn required_approvals_zero_rejected() {
650 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
651 let mut session = base_session();
652 let result = mode
653 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
654 .unwrap();
655 apply(&mut session, result);
656 let err = mode
657 .on_message(
658 &session,
659 &env(
660 "coordinator",
661 "ApprovalRequest",
662 make_approval_request("r1", 0),
663 ),
664 )
665 .unwrap_err();
666 assert_eq!(err.to_string(), "InvalidPayload");
667 }
668
669 #[test]
670 fn non_coordinator_approval_request_rejected() {
671 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
672 let mut session = base_session();
673 let result = mode
674 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
675 .unwrap();
676 apply(&mut session, result);
677 let err = mode
678 .on_message(
679 &session,
680 &env("alice", "ApprovalRequest", make_approval_request("r1", 2)),
681 )
682 .unwrap_err();
683 assert_eq!(err.to_string(), "Forbidden");
684 }
685
686 #[test]
689 fn participant_can_approve() {
690 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
691 let mut session = base_session();
692 let result = mode
693 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
694 .unwrap();
695 apply(&mut session, result);
696 let result = mode
697 .on_message(
698 &session,
699 &env(
700 "coordinator",
701 "ApprovalRequest",
702 make_approval_request("r1", 2),
703 ),
704 )
705 .unwrap();
706 apply(&mut session, result);
707 let result = mode
708 .on_message(
709 &session,
710 &env("alice", "Approve", make_approve("r1", "looks good")),
711 )
712 .unwrap();
713 match result {
714 ModeResponse::PersistState(data) => {
715 let state: QuorumState = serde_json::from_slice(&data).unwrap();
716 assert!(state.ballots.contains_key("alice"));
717 assert_eq!(state.ballots["alice"].choice, BallotChoice::Approve);
718 }
719 _ => panic!("Expected PersistState"),
720 }
721 }
722
723 #[test]
724 fn participant_can_reject() {
725 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
726 let mut session = base_session();
727 let result = mode
728 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
729 .unwrap();
730 apply(&mut session, result);
731 let result = mode
732 .on_message(
733 &session,
734 &env(
735 "coordinator",
736 "ApprovalRequest",
737 make_approval_request("r1", 2),
738 ),
739 )
740 .unwrap();
741 apply(&mut session, result);
742 let result = mode
743 .on_message(
744 &session,
745 &env("alice", "Reject", make_reject("r1", "not ready")),
746 )
747 .unwrap();
748 match result {
749 ModeResponse::PersistState(data) => {
750 let state: QuorumState = serde_json::from_slice(&data).unwrap();
751 assert_eq!(state.ballots["alice"].choice, BallotChoice::Reject);
752 }
753 _ => panic!("Expected PersistState"),
754 }
755 }
756
757 #[test]
758 fn participant_can_abstain() {
759 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
760 let mut session = base_session();
761 let result = mode
762 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
763 .unwrap();
764 apply(&mut session, result);
765 let result = mode
766 .on_message(
767 &session,
768 &env(
769 "coordinator",
770 "ApprovalRequest",
771 make_approval_request("r1", 2),
772 ),
773 )
774 .unwrap();
775 apply(&mut session, result);
776 let result = mode
777 .on_message(
778 &session,
779 &env("alice", "Abstain", make_abstain("r1", "no opinion")),
780 )
781 .unwrap();
782 match result {
783 ModeResponse::PersistState(data) => {
784 let state: QuorumState = serde_json::from_slice(&data).unwrap();
785 assert_eq!(state.ballots["alice"].choice, BallotChoice::Abstain);
786 }
787 _ => panic!("Expected PersistState"),
788 }
789 }
790
791 #[test]
792 fn duplicate_ballot_rejected() {
793 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
794 let mut session = base_session();
795 let result = mode
796 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
797 .unwrap();
798 apply(&mut session, result);
799 let result = mode
800 .on_message(
801 &session,
802 &env(
803 "coordinator",
804 "ApprovalRequest",
805 make_approval_request("r1", 2),
806 ),
807 )
808 .unwrap();
809 apply(&mut session, result);
810 let result = mode
811 .on_message(
812 &session,
813 &env("alice", "Approve", make_approve("r1", "yes")),
814 )
815 .unwrap();
816 apply(&mut session, result);
817 let err = mode
818 .on_message(
819 &session,
820 &env("alice", "Approve", make_approve("r1", "again")),
821 )
822 .unwrap_err();
823 assert_eq!(err.to_string(), "InvalidPayload");
824 }
825
826 #[test]
827 fn ballot_before_request_rejected() {
828 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
829 let mut session = base_session();
830 let result = mode
831 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
832 .unwrap();
833 apply(&mut session, result);
834 let err = mode
835 .on_message(
836 &session,
837 &env("alice", "Approve", make_approve("r1", "premature")),
838 )
839 .unwrap_err();
840 assert_eq!(err.to_string(), "InvalidPayload");
841 }
842
843 #[test]
844 fn wrong_request_id_rejected() {
845 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
846 let mut session = base_session();
847 let result = mode
848 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
849 .unwrap();
850 apply(&mut session, result);
851 let result = mode
852 .on_message(
853 &session,
854 &env(
855 "coordinator",
856 "ApprovalRequest",
857 make_approval_request("r1", 2),
858 ),
859 )
860 .unwrap();
861 apply(&mut session, result);
862 let err = mode
863 .on_message(
864 &session,
865 &env("alice", "Approve", make_approve("r2", "wrong id")),
866 )
867 .unwrap_err();
868 assert_eq!(err.to_string(), "InvalidPayload");
869 }
870
871 #[test]
874 fn commitment_when_threshold_reached() {
875 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
876 let mut session = base_session();
877 let result = mode
878 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
879 .unwrap();
880 apply(&mut session, result);
881 let result = mode
882 .on_message(
883 &session,
884 &env(
885 "coordinator",
886 "ApprovalRequest",
887 make_approval_request("r1", 2),
888 ),
889 )
890 .unwrap();
891 apply(&mut session, result);
892 let result = mode
893 .on_message(
894 &session,
895 &env("alice", "Approve", make_approve("r1", "yes")),
896 )
897 .unwrap();
898 apply(&mut session, result);
899 let result = mode
900 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
901 .unwrap();
902 apply(&mut session, result);
903 let result = mode
904 .on_message(
905 &session,
906 &env("coordinator", "Commitment", commitment_payload()),
907 )
908 .unwrap();
909 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
910 }
911
912 #[test]
913 fn commitment_when_threshold_unreachable() {
914 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
915 let mut session = base_session();
916 let result = mode
917 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
918 .unwrap();
919 apply(&mut session, result);
920 let result = mode
921 .on_message(
922 &session,
923 &env(
924 "coordinator",
925 "ApprovalRequest",
926 make_approval_request("r1", 3),
927 ),
928 )
929 .unwrap();
930 apply(&mut session, result);
931 let result = mode
933 .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
934 .unwrap();
935 apply(&mut session, result);
936 let result = mode
938 .on_message(
939 &session,
940 &env("coordinator", "Commitment", commitment_payload()),
941 )
942 .unwrap();
943 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
944 }
945
946 #[test]
947 fn commitment_before_threshold_rejected() {
948 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
949 let mut session = base_session();
950 let result = mode
951 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
952 .unwrap();
953 apply(&mut session, result);
954 let result = mode
955 .on_message(
956 &session,
957 &env(
958 "coordinator",
959 "ApprovalRequest",
960 make_approval_request("r1", 2),
961 ),
962 )
963 .unwrap();
964 apply(&mut session, result);
965 let result = mode
966 .on_message(
967 &session,
968 &env("alice", "Approve", make_approve("r1", "yes")),
969 )
970 .unwrap();
971 apply(&mut session, result);
972 let err = mode
974 .on_message(
975 &session,
976 &env("coordinator", "Commitment", commitment_payload()),
977 )
978 .unwrap_err();
979 assert_eq!(err.to_string(), "InvalidPayload");
980 }
981
982 #[test]
983 fn non_coordinator_commitment_rejected() {
984 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
985 let mut session = base_session();
986 let result = mode
987 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
988 .unwrap();
989 apply(&mut session, result);
990 let result = mode
991 .on_message(
992 &session,
993 &env(
994 "coordinator",
995 "ApprovalRequest",
996 make_approval_request("r1", 2),
997 ),
998 )
999 .unwrap();
1000 apply(&mut session, result);
1001 let result = mode
1002 .on_message(
1003 &session,
1004 &env("alice", "Approve", make_approve("r1", "yes")),
1005 )
1006 .unwrap();
1007 apply(&mut session, result);
1008 let result = mode
1009 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1010 .unwrap();
1011 apply(&mut session, result);
1012 let commit_env = env("alice", "Commitment", commitment_payload());
1013 let err = mode.authorize_sender(&session, &commit_env).unwrap_err();
1014 assert_eq!(err.to_string(), "Forbidden");
1015 }
1016
1017 #[test]
1020 fn full_quorum_approve_lifecycle() {
1021 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1022 let mut session = base_session();
1023 let result = mode
1024 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1025 .unwrap();
1026 apply(&mut session, result);
1027 let result = mode
1028 .on_message(
1029 &session,
1030 &env(
1031 "coordinator",
1032 "ApprovalRequest",
1033 make_approval_request("r1", 2),
1034 ),
1035 )
1036 .unwrap();
1037 apply(&mut session, result);
1038 let result = mode
1039 .on_message(
1040 &session,
1041 &env("alice", "Approve", make_approve("r1", "green")),
1042 )
1043 .unwrap();
1044 apply(&mut session, result);
1045 let result = mode
1046 .on_message(
1047 &session,
1048 &env("bob", "Approve", make_approve("r1", "ready")),
1049 )
1050 .unwrap();
1051 apply(&mut session, result);
1052 let result = mode
1053 .on_message(
1054 &session,
1055 &env("coordinator", "Commitment", commitment_payload()),
1056 )
1057 .unwrap();
1058 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1059 }
1060
1061 #[test]
1062 fn full_quorum_reject_lifecycle() {
1063 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1064 let mut session = base_session();
1065 let result = mode
1066 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1067 .unwrap();
1068 apply(&mut session, result);
1069 let result = mode
1070 .on_message(
1071 &session,
1072 &env(
1073 "coordinator",
1074 "ApprovalRequest",
1075 make_approval_request("r1", 3),
1076 ),
1077 )
1078 .unwrap();
1079 apply(&mut session, result);
1080 let result = mode
1081 .on_message(
1082 &session,
1083 &env("alice", "Reject", make_reject("r1", "not ready")),
1084 )
1085 .unwrap();
1086 apply(&mut session, result);
1087 let result = mode
1089 .on_message(
1090 &session,
1091 &env("coordinator", "Commitment", commitment_payload()),
1092 )
1093 .unwrap();
1094 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1095 }
1096
1097 #[test]
1100 fn commitment_version_mismatch_rejected() {
1101 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1102 let mut session = base_session();
1103 let result = mode
1104 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1105 .unwrap();
1106 apply(&mut session, result);
1107 let result = mode
1108 .on_message(
1109 &session,
1110 &env(
1111 "coordinator",
1112 "ApprovalRequest",
1113 make_approval_request("r1", 2),
1114 ),
1115 )
1116 .unwrap();
1117 apply(&mut session, result);
1118 let result = mode
1119 .on_message(
1120 &session,
1121 &env("alice", "Approve", make_approve("r1", "yes")),
1122 )
1123 .unwrap();
1124 apply(&mut session, result);
1125 let result = mode
1126 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1127 .unwrap();
1128 apply(&mut session, result);
1129 let bad_commitment = CommitmentPayload {
1130 commitment_id: "c1".into(),
1131 action: "quorum.approved".into(),
1132 authority_scope: "deploy".into(),
1133 reason: "threshold met".into(),
1134 mode_version: "wrong".into(),
1135 policy_version: "policy".into(),
1136 configuration_version: "config".into(),
1137 outcome_positive: true,
1138 supersedes: None,
1139 }
1140 .encode_to_vec();
1141 let err = mode
1142 .on_message(&session, &env("coordinator", "Commitment", bad_commitment))
1143 .unwrap_err();
1144 assert_eq!(err.to_string(), "InvalidPayload");
1145 }
1146
1147 #[test]
1150 fn unknown_message_type_rejected() {
1151 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1152 let mut session = base_session();
1153 let result = mode
1154 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1155 .unwrap();
1156 apply(&mut session, result);
1157 let err = mode
1158 .on_message(&session, &env("alice", "CustomType", vec![]))
1159 .unwrap_err();
1160 assert_eq!(err.to_string(), "InvalidPayload");
1161 }
1162
1163 #[test]
1166 fn policy_denies_commitment_when_quorum_not_met_due_to_abstentions() {
1167 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1168 let mut session = base_session();
1169 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1171 policy_id: "test-strict-quorum".into(),
1172 mode: "macp.mode.quorum.v1".into(),
1173 description: "strict quorum".into(),
1174 rules: serde_json::json!({
1175 "threshold": { "type": "n_of_m", "value": 3 },
1176 "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" }
1177 }),
1178 schema_version: 1,
1179 });
1180 let result = mode
1181 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1182 .unwrap();
1183 apply(&mut session, result);
1184 let result = mode
1185 .on_message(
1186 &session,
1187 &env(
1188 "coordinator",
1189 "ApprovalRequest",
1190 make_approval_request("r1", 2),
1191 ),
1192 )
1193 .unwrap();
1194 apply(&mut session, result);
1195 let result = mode
1196 .on_message(
1197 &session,
1198 &env("alice", "Approve", make_approve("r1", "yes")),
1199 )
1200 .unwrap();
1201 apply(&mut session, result);
1202 let result = mode
1203 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1204 .unwrap();
1205 apply(&mut session, result);
1206 let result = mode
1208 .on_message(
1209 &session,
1210 &env("carol", "Abstain", make_abstain("r1", "no opinion")),
1211 )
1212 .unwrap();
1213 apply(&mut session, result);
1214 let err = mode
1216 .on_message(
1217 &session,
1218 &env("coordinator", "Commitment", commitment_payload()),
1219 )
1220 .unwrap_err();
1221 assert_eq!(err.to_string(), "PolicyDenied");
1222 }
1223
1224 #[test]
1227 fn negative_outcome_quorum_rejected() {
1228 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1229 let mut session = base_session();
1230 let result = mode
1232 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1233 .unwrap();
1234 apply(&mut session, result);
1235 let result = mode
1236 .on_message(
1237 &session,
1238 &env(
1239 "coordinator",
1240 "ApprovalRequest",
1241 make_approval_request("r1", 2),
1242 ),
1243 )
1244 .unwrap();
1245 apply(&mut session, result);
1246 let result = mode
1248 .on_message(
1249 &session,
1250 &env("alice", "Reject", make_reject("r1", "not ready")),
1251 )
1252 .unwrap();
1253 apply(&mut session, result);
1254 let result = mode
1255 .on_message(
1256 &session,
1257 &env("bob", "Reject", make_reject("r1", "disagree")),
1258 )
1259 .unwrap();
1260 apply(&mut session, result);
1261 let negative_commitment = commitment("quorum.rejected", false);
1264 let result = mode
1265 .on_message(
1266 &session,
1267 &env("coordinator", "Commitment", negative_commitment),
1268 )
1269 .unwrap();
1270 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1271 }
1272
1273 #[test]
1276 fn all_participants_abstain_allows_negative_commitment() {
1277 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1278 let mut session = base_session();
1279 let result = mode
1281 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1282 .unwrap();
1283 apply(&mut session, result);
1284 let result = mode
1285 .on_message(
1286 &session,
1287 &env(
1288 "coordinator",
1289 "ApprovalRequest",
1290 make_approval_request("r1", 2),
1291 ),
1292 )
1293 .unwrap();
1294 apply(&mut session, result);
1295 let result = mode
1297 .on_message(
1298 &session,
1299 &env("alice", "Abstain", make_abstain("r1", "neutral")),
1300 )
1301 .unwrap();
1302 apply(&mut session, result);
1303 let result = mode
1304 .on_message(
1305 &session,
1306 &env("bob", "Abstain", make_abstain("r1", "neutral")),
1307 )
1308 .unwrap();
1309 apply(&mut session, result);
1310 let result = mode
1311 .on_message(
1312 &session,
1313 &env("carol", "Abstain", make_abstain("r1", "neutral")),
1314 )
1315 .unwrap();
1316 apply(&mut session, result);
1317 let negative_commitment = CommitmentPayload {
1320 commitment_id: "c1".into(),
1321 action: "quorum.rejected".into(),
1322 authority_scope: "deploy".into(),
1323 reason: "all abstained".into(),
1324 mode_version: "1.0.0".into(),
1325 policy_version: "policy".into(),
1326 configuration_version: "config".into(),
1327 outcome_positive: false,
1328 supersedes: None,
1329 }
1330 .encode_to_vec();
1331 let result = mode
1332 .on_message(
1333 &session,
1334 &env("coordinator", "Commitment", negative_commitment),
1335 )
1336 .unwrap();
1337 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1338 }
1339
1340 #[test]
1343 fn initiator_not_in_participants_cannot_cast_ballot() {
1344 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1345 let mut session = base_session();
1346 let result = mode
1349 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1350 .unwrap();
1351 apply(&mut session, result);
1352 let result = mode
1353 .on_message(
1354 &session,
1355 &env(
1356 "coordinator",
1357 "ApprovalRequest",
1358 make_approval_request("r1", 2),
1359 ),
1360 )
1361 .unwrap();
1362 apply(&mut session, result);
1363 let approve_env = env("coordinator", "Approve", make_approve("r1", "yes"));
1366 let err = mode.authorize_sender(&session, &approve_env).unwrap_err();
1367 assert_eq!(err.to_string(), "Forbidden");
1368 }
1369
1370 #[test]
1373 fn policy_threshold_overrides_required_approvals() {
1374 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1375 let mut session = base_session();
1376 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1378 policy_id: "threshold-override".into(),
1379 mode: "macp.mode.quorum.v1".into(),
1380 description: "low threshold".into(),
1381 rules: serde_json::json!({
1382 "threshold": { "type": "n_of_m", "value": 1.0 }
1383 }),
1384 schema_version: 1,
1385 });
1386 let result = mode
1387 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1388 .unwrap();
1389 apply(&mut session, result);
1390 let result = mode
1391 .on_message(
1392 &session,
1393 &env(
1394 "coordinator",
1395 "ApprovalRequest",
1396 make_approval_request("r1", 3), ),
1398 )
1399 .unwrap();
1400 apply(&mut session, result);
1401 let result = mode
1403 .on_message(
1404 &session,
1405 &env("alice", "Approve", make_approve("r1", "yes")),
1406 )
1407 .unwrap();
1408 apply(&mut session, result);
1409 let commit = mode
1411 .on_message(
1412 &session,
1413 &env("coordinator", "Commitment", commitment_payload()),
1414 )
1415 .unwrap();
1416 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1417 }
1418
1419 #[test]
1420 fn policy_percentage_threshold() {
1421 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1422 let mut session = base_session();
1423 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1425 policy_id: "pct-override".into(),
1426 mode: "macp.mode.quorum.v1".into(),
1427 description: "percentage threshold".into(),
1428 rules: serde_json::json!({
1429 "threshold": { "type": "percentage", "value": 50.0 }
1430 }),
1431 schema_version: 1,
1432 });
1433 let result = mode
1434 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1435 .unwrap();
1436 apply(&mut session, result);
1437 let result = mode
1438 .on_message(
1439 &session,
1440 &env(
1441 "coordinator",
1442 "ApprovalRequest",
1443 make_approval_request("r1", 3),
1444 ),
1445 )
1446 .unwrap();
1447 apply(&mut session, result);
1448 let result = mode
1450 .on_message(
1451 &session,
1452 &env("alice", "Approve", make_approve("r1", "yes")),
1453 )
1454 .unwrap();
1455 apply(&mut session, result);
1456 let err = mode
1457 .on_message(
1458 &session,
1459 &env("coordinator", "Commitment", commitment_payload()),
1460 )
1461 .unwrap_err();
1462 assert_eq!(err.to_string(), "InvalidPayload");
1463 let result = mode
1465 .on_message(
1466 &session,
1467 &env("bob", "Approve", make_approve("r1", "agreed")),
1468 )
1469 .unwrap();
1470 apply(&mut session, result);
1471 let commit = mode
1472 .on_message(
1473 &session,
1474 &env("coordinator", "Commitment", commitment_payload()),
1475 )
1476 .unwrap();
1477 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1478 }
1479
1480 #[test]
1481 fn all_abstain_eligible_for_negative_commitment() {
1482 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1485 let mut session = base_session();
1486 let result = mode
1487 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1488 .unwrap();
1489 apply(&mut session, result);
1490 let result = mode
1491 .on_message(
1492 &session,
1493 &env(
1494 "coordinator",
1495 "ApprovalRequest",
1496 make_approval_request("r1", 2),
1497 ),
1498 )
1499 .unwrap();
1500 apply(&mut session, result);
1501 for sender in &["alice", "bob", "carol"] {
1503 let result = mode
1504 .on_message(
1505 &session,
1506 &env(sender, "Abstain", make_abstain("r1", "neutral")),
1507 )
1508 .unwrap();
1509 apply(&mut session, result);
1510 }
1511 let commit = mode
1513 .on_message(
1514 &session,
1515 &env(
1516 "coordinator",
1517 "Commitment",
1518 commitment("quorum.rejected", false),
1519 ),
1520 )
1521 .unwrap();
1522 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1523 }
1524
1525 fn quorum_policy(rules: serde_json::Value) -> macp_core::policy::PolicyDefinition {
1528 macp_core::policy::PolicyDefinition {
1529 policy_id: "threshold-parity".into(),
1530 mode: "macp.mode.quorum.v1".into(),
1531 description: "threshold parity fixture".into(),
1532 rules,
1533 schema_version: 1,
1534 }
1535 }
1536
1537 fn session_with(participants: usize, rules: serde_json::Value) -> Session {
1538 let names = ["alice", "bob", "carol", "dave", "erin"];
1539 let mut session = Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
1540 .ttl_ms(60_000)
1541 .participants(
1542 names[..participants]
1543 .iter()
1544 .map(|s| s.to_string())
1545 .collect(),
1546 )
1547 .mode_version("1.0.0")
1548 .configuration_version("config")
1549 .policy_version("policy")
1550 .build();
1551 session.policy_definition = Some(quorum_policy(rules));
1552 session
1553 }
1554
1555 fn matrix_request() -> ApprovalRequestRecord {
1559 ApprovalRequestRecord {
1560 request_id: "r1".into(),
1561 action: "deploy.production".into(),
1562 summary: "Deploy v2".into(),
1563 details: vec![],
1564 required_approvals: 99,
1565 requested_by: "coordinator".into(),
1566 }
1567 }
1568
1569 fn as_option(threshold: ApprovalThreshold) -> Option<u32> {
1572 match threshold {
1573 ApprovalThreshold::Approvals(required) => Some(required),
1574 ApprovalThreshold::Unsatisfiable => None,
1575 }
1576 }
1577
1578 fn mode_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1580 let session = session_with(participants, rules);
1581 as_option(QuorumMode::effective_threshold(&session, &matrix_request()))
1582 }
1583
1584 fn session_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1592 let mut session = session_with(participants, rules);
1593 session.mode_state = QuorumMode::encode_state(&QuorumState {
1594 request: Some(matrix_request()),
1595 ballots: BTreeMap::new(),
1596 });
1597 as_option(
1598 QuorumMode::effective_threshold_for_session(&session)
1599 .expect("state seated by encode_state decodes")
1600 .expect("a request was seated"),
1601 )
1602 }
1603
1604 fn evaluator_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1609 let policy = quorum_policy(rules);
1610 (0u32..=64).find(|approve| {
1611 matches!(
1612 macp_policy::evaluator::evaluate_quorum_commitment_outcome(
1613 &policy,
1614 *approve as usize,
1615 0,
1616 0,
1617 participants,
1618 true,
1619 ),
1620 macp_core::policy::PolicyDecision::Allow { .. }
1621 )
1622 })
1623 }
1624
1625 #[test]
1626 fn fractional_threshold_ceils_to_one_in_both_layers() {
1627 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } });
1630 assert_eq!(mode_required(3, rules.clone()), Some(1));
1631 assert_eq!(evaluator_required(3, rules), Some(1));
1632 }
1633
1634 #[test]
1635 fn threshold_is_floored_at_one_so_a_zero_bar_is_unreachable() {
1636 for value in [0.000_001, 0.1, 0.49, 0.5, 0.99] {
1638 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": value } });
1639 assert_eq!(mode_required(3, rules.clone()), Some(1), "value {value}");
1640 assert_eq!(evaluator_required(3, rules), Some(1), "value {value}");
1641 }
1642 }
1643
1644 #[test]
1645 fn zero_ballot_decline_is_refused_under_a_fractional_threshold() {
1646 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1649 let mut session = session_with(
1650 3,
1651 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } }),
1652 );
1653 let result = mode
1654 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1655 .unwrap();
1656 apply(&mut session, result);
1657 let result = mode
1658 .on_message(
1659 &session,
1660 &env(
1661 "coordinator",
1662 "ApprovalRequest",
1663 make_approval_request("r1", 3),
1664 ),
1665 )
1666 .unwrap();
1667 apply(&mut session, result);
1668 let err = mode
1669 .on_message(
1670 &session,
1671 &env(
1672 "coordinator",
1673 "Commitment",
1674 commitment("quorum.rejected", false),
1675 ),
1676 )
1677 .unwrap_err();
1678 assert_eq!(err.to_string(), "InvalidPayload");
1679 let err = mode
1681 .on_message(
1682 &session,
1683 &env("coordinator", "Commitment", commitment_payload()),
1684 )
1685 .unwrap_err();
1686 assert_eq!(err.to_string(), "InvalidPayload");
1687 let result = mode
1689 .on_message(
1690 &session,
1691 &env("alice", "Approve", make_approve("r1", "yes")),
1692 )
1693 .unwrap();
1694 apply(&mut session, result);
1695 let commit = mode
1696 .on_message(
1697 &session,
1698 &env("coordinator", "Commitment", commitment_payload()),
1699 )
1700 .unwrap();
1701 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1702 }
1703
1704 #[test]
1705 fn weighted_threshold_is_unsatisfiable_not_a_raw_approval_count() {
1706 let rules = serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } });
1711 assert_eq!(mode_required(3, rules.clone()), None);
1712 assert_eq!(evaluator_required(3, rules.clone()), None);
1713
1714 let unknown = serde_json::json!({ "threshold": { "type": "two_thirds", "value": 2 } });
1716 assert_eq!(mode_required(3, unknown.clone()), None);
1717 assert_eq!(evaluator_required(3, unknown), None);
1718
1719 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1722 let mut session = session_with(3, rules);
1723 let result = mode
1724 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1725 .unwrap();
1726 apply(&mut session, result);
1727 let err = mode
1728 .on_message(
1729 &session,
1730 &env(
1731 "coordinator",
1732 "ApprovalRequest",
1733 make_approval_request("r1", 2),
1734 ),
1735 )
1736 .unwrap_err();
1737 assert_eq!(err.to_string(), "InvalidPayload");
1738 }
1739
1740 #[test]
1741 fn mode_and_evaluator_agree_across_the_threshold_matrix() {
1742 let cases: [(&str, [f64; 5]); 3] = [
1754 ("n_of_m", [0.5, 1.0, 2.0, 3.0, 5.0]),
1755 ("count", [0.5, 1.0, 2.0, 3.0, 5.0]),
1756 ("percentage", [0.5, 33.0, 50.0, 100.0, 150.0]),
1758 ];
1759 for (threshold_type, values) in cases {
1760 for value in values {
1761 for participants in 1..=3usize {
1762 let rules = serde_json::json!({
1763 "threshold": { "type": threshold_type, "value": value }
1764 });
1765 let mode = mode_required(participants, rules.clone());
1766 let session = session_required(participants, rules.clone());
1767 let evaluator = evaluator_required(participants, rules);
1768 assert_eq!(
1769 mode, evaluator,
1770 "type={threshold_type} value={value} participants={participants}"
1771 );
1772 assert_eq!(
1776 session, evaluator,
1777 "session-level accessor: type={threshold_type} value={value} \
1778 participants={participants}"
1779 );
1780 assert_ne!(
1781 mode,
1782 Some(0),
1783 "type={threshold_type} value={value} participants={participants}: \
1784 a bar of 0 is met before any ballot is cast"
1785 );
1786 }
1787 }
1788 }
1789 }
1790
1791 #[test]
1792 fn session_threshold_separates_no_request_from_unsatisfiable() {
1793 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } });
1799
1800 let mut session = session_with(3, rules.clone());
1803 assert_eq!(
1804 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1805 None
1806 );
1807 session.mode_state = QuorumMode::encode_state(&QuorumState::default());
1808 assert_eq!(
1809 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1810 None
1811 );
1812
1813 session.mode_state = QuorumMode::encode_state(&QuorumState {
1815 request: Some(matrix_request()),
1816 ballots: BTreeMap::new(),
1817 });
1818 assert_eq!(
1819 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1820 Some(ApprovalThreshold::Approvals(2))
1821 );
1822
1823 session.policy_definition = Some(quorum_policy(
1826 serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } }),
1827 ));
1828 assert_eq!(
1829 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1830 Some(ApprovalThreshold::Unsatisfiable)
1831 );
1832
1833 session.mode_state = b"{not-quorum-state".to_vec();
1836 assert_eq!(
1837 QuorumMode::effective_threshold_for_session(&session)
1838 .unwrap_err()
1839 .to_string(),
1840 "InvalidModeState"
1841 );
1842 }
1843
1844 #[test]
1845 fn session_threshold_falls_back_to_the_requests_own_required_approvals() {
1846 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1852 let mut session = base_session();
1853 session.policy_definition = None;
1854 let result = mode
1855 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1856 .unwrap();
1857 apply(&mut session, result);
1858 assert_eq!(
1859 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1860 None
1861 );
1862 let result = mode
1863 .on_message(
1864 &session,
1865 &env(
1866 "coordinator",
1867 "ApprovalRequest",
1868 make_approval_request("r1", 2),
1869 ),
1870 )
1871 .unwrap();
1872 apply(&mut session, result);
1873 assert_eq!(
1874 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1875 Some(ApprovalThreshold::Approvals(2))
1876 );
1877 session.policy_definition = Some(quorum_policy(
1880 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0 } }),
1881 ));
1882 assert_eq!(
1883 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1884 Some(ApprovalThreshold::Approvals(2))
1885 );
1886 }
1887
1888 #[test]
1889 fn over_participant_policy_threshold_refuses_the_approval_request() {
1890 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1896 let mut session = session_with(
1897 3,
1898 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
1899 );
1900 let result = mode
1901 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1902 .unwrap();
1903 apply(&mut session, result);
1904 let err = mode
1905 .on_message(
1906 &session,
1907 &env(
1908 "coordinator",
1909 "ApprovalRequest",
1910 make_approval_request("r1", 3),
1911 ),
1912 )
1913 .unwrap_err();
1914 assert_eq!(err.to_string(), "InvalidPayload");
1915 session.policy_definition = Some(quorum_policy(
1917 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
1918 ));
1919 assert!(mode
1920 .on_message(
1921 &session,
1922 &env(
1923 "coordinator",
1924 "ApprovalRequest",
1925 make_approval_request("r1", 3),
1926 ),
1927 )
1928 .is_ok());
1929 }
1930
1931 #[test]
1932 fn zero_ballot_decline_is_refused_when_policy_rebinds_over_the_participant_pool() {
1933 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1939 let mut session = session_with(
1940 3,
1941 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
1942 );
1943 let result = mode
1944 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1945 .unwrap();
1946 apply(&mut session, result);
1947 let result = mode
1948 .on_message(
1949 &session,
1950 &env(
1951 "coordinator",
1952 "ApprovalRequest",
1953 make_approval_request("r1", 3),
1954 ),
1955 )
1956 .unwrap();
1957 apply(&mut session, result);
1958 session.policy_definition = Some(quorum_policy(
1960 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
1961 ));
1962 let err = mode
1963 .on_message(
1964 &session,
1965 &env(
1966 "coordinator",
1967 "Commitment",
1968 commitment("quorum.rejected", false),
1969 ),
1970 )
1971 .unwrap_err();
1972 assert_eq!(err.to_string(), "InvalidPayload");
1973 }
1974
1975 #[test]
1976 fn genuine_unreachable_threshold_still_permits_a_negative_commitment() {
1977 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1982 let mut session = session_with(
1983 3,
1984 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } }),
1985 );
1986 let result = mode
1987 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1988 .unwrap();
1989 apply(&mut session, result);
1990 let result = mode
1991 .on_message(
1992 &session,
1993 &env(
1994 "coordinator",
1995 "ApprovalRequest",
1996 make_approval_request("r1", 3),
1997 ),
1998 )
1999 .unwrap();
2000 apply(&mut session, result);
2001 for participant in ["alice", "bob", "carol"] {
2002 let result = mode
2003 .on_message(
2004 &session,
2005 &env(participant, "Abstain", make_abstain("r1", "no opinion")),
2006 )
2007 .unwrap();
2008 apply(&mut session, result);
2009 }
2010 let commit = mode
2011 .on_message(
2012 &session,
2013 &env(
2014 "coordinator",
2015 "Commitment",
2016 commitment("quorum.rejected", false),
2017 ),
2018 )
2019 .unwrap();
2020 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2021 }
2022
2023 #[test]
2024 fn a_single_ballot_still_unlocks_the_unreachable_branch() {
2025 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2029 let mut session = session_with(
2030 3,
2031 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
2032 );
2033 let result = mode
2034 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
2035 .unwrap();
2036 apply(&mut session, result);
2037 let result = mode
2038 .on_message(
2039 &session,
2040 &env(
2041 "coordinator",
2042 "ApprovalRequest",
2043 make_approval_request("r1", 3),
2044 ),
2045 )
2046 .unwrap();
2047 apply(&mut session, result);
2048 let result = mode
2049 .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
2050 .unwrap();
2051 apply(&mut session, result);
2052 let commit = mode
2053 .on_message(
2054 &session,
2055 &env(
2056 "coordinator",
2057 "Commitment",
2058 commitment("quorum.rejected", false),
2059 ),
2060 )
2061 .unwrap();
2062 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2063 }
2064}