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)]
38#[non_exhaustive]
39pub struct ApprovalRequestRecord {
40 pub request_id: String,
41 pub action: String,
42 pub summary: String,
43 pub details: Vec<u8>,
44 pub required_approvals: u32,
45 pub requested_by: String,
46}
47
48impl ApprovalRequestRecord {
49 pub fn new(request_id: impl Into<String>, required_approvals: u32) -> Self {
82 Self {
83 request_id: request_id.into(),
84 action: String::new(),
85 summary: String::new(),
86 details: Vec::new(),
87 required_approvals,
88 requested_by: String::new(),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
97#[non_exhaustive]
98pub struct BallotRecord {
99 pub request_id: String,
100 pub choice: BallotChoice,
101 pub sender: String,
102 pub reason: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112#[non_exhaustive]
113pub struct QuorumState {
114 pub request: Option<ApprovalRequestRecord>,
115 pub ballots: BTreeMap<String, BallotRecord>,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum ApprovalThreshold {
145 Approvals(u32),
152 Unsatisfiable,
166}
167
168pub struct QuorumMode {
169 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
170}
171
172impl QuorumMode {
173 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
175 Self { evaluator }
176 }
177
178 fn encode_state(state: &QuorumState) -> Vec<u8> {
179 crate::mode::util::encode_mode_state(state)
180 }
181
182 fn decode_state(data: &[u8]) -> Result<QuorumState, MacpError> {
183 crate::mode::util::decode_mode_state(data)
184 }
185
186 pub fn effective_threshold(
214 session: &Session,
215 request: &ApprovalRequestRecord,
216 ) -> ApprovalThreshold {
217 let Some(ref policy) = session.policy_definition else {
218 return ApprovalThreshold::Approvals(request.required_approvals);
219 };
220 let rules: macp_core::policy::rules::QuorumPolicyRules =
221 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
222 match rules.threshold.effective(session.participants.len()) {
223 macp_core::policy::rules::EffectiveThreshold::Inert => {
224 ApprovalThreshold::Approvals(request.required_approvals)
225 }
226 macp_core::policy::rules::EffectiveThreshold::Approvals(required) => {
227 ApprovalThreshold::Approvals(required)
228 }
229 macp_core::policy::rules::EffectiveThreshold::Unsatisfiable => {
230 ApprovalThreshold::Unsatisfiable
231 }
232 }
233 }
234
235 pub fn effective_threshold_for_session(
280 session: &Session,
281 ) -> Result<Option<ApprovalThreshold>, MacpError> {
282 if session.mode_state.is_empty() {
283 return Ok(None);
284 }
285 let state = Self::decode_state(&session.mode_state)?;
286 Ok(state
287 .request
288 .as_ref()
289 .map(|request| Self::effective_threshold(session, request)))
290 }
291
292 fn commitment_ready(session: &Session, state: &QuorumState) -> bool {
293 let request = match &state.request {
294 Some(request) => request,
295 None => return false,
296 };
297 let ApprovalThreshold::Approvals(required) = Self::effective_threshold(session, request)
298 else {
299 return false;
303 };
304 let approvals = state
305 .ballots
306 .values()
307 .filter(|ballot| ballot.choice == BallotChoice::Approve)
308 .count() as u32;
309 let total_eligible = session.participants.len() as u32;
310 let counted = state.ballots.len() as u32;
311 let remaining = total_eligible.saturating_sub(counted);
312 approvals >= required || (counted > 0 && approvals + remaining < required)
328 }
329}
330
331impl Mode for QuorumMode {
332 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
333 match env.message_type.as_str() {
334 "ApprovalRequest" if env.sender == session.initiator_sender => Ok(()),
335 "ApprovalRequest" => Err(MacpError::Forbidden),
336 "Commitment" => check_commitment_authority(session, &env.sender),
337 _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
338 _ => Err(MacpError::Forbidden),
339 }
340 }
341
342 fn on_session_start(
343 &self,
344 session: &Session,
345 _env: &Envelope,
346 ) -> Result<ModeResponse, MacpError> {
347 if session.participants.is_empty() {
348 return Err(MacpError::InvalidPayload);
349 }
350 Ok(ModeResponse::PersistState(Self::encode_state(
351 &QuorumState::default(),
352 )))
353 }
354
355 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
356 let mut state = if session.mode_state.is_empty() {
357 QuorumState::default()
358 } else {
359 Self::decode_state(&session.mode_state)?
360 };
361
362 match env.message_type.as_str() {
363 "ApprovalRequest" => {
364 if env.sender != session.initiator_sender {
365 return Err(MacpError::Forbidden);
366 }
367 let payload = ApprovalRequestPayload::decode(&*env.payload)
368 .map_err(|_| MacpError::InvalidPayload)?;
369 if state.request.is_some()
370 || payload.request_id.is_empty()
371 || payload.required_approvals == 0
372 || payload.required_approvals > session.participants.len() as u32
373 {
374 return Err(MacpError::InvalidPayload);
375 }
376 let record = ApprovalRequestRecord {
377 request_id: payload.request_id,
378 action: payload.action,
379 summary: payload.summary,
380 details: payload.details,
381 required_approvals: payload.required_approvals,
382 requested_by: env.sender.clone(),
383 };
384 match Self::effective_threshold(session, &record) {
395 ApprovalThreshold::Approvals(required)
396 if required >= 1 && required <= session.participants.len() as u32 => {}
397 other => {
398 tracing::warn!(
399 session_id = %session.session_id,
400 policy_id = session
401 .policy_definition
402 .as_ref()
403 .map(|p| p.policy_id.as_str())
404 .unwrap_or(""),
405 effective_threshold = ?other,
406 participants = session.participants.len(),
407 "quorum policy threshold is outside 1..=participants; \
408 refusing the ApprovalRequest"
409 );
410 return Err(MacpError::InvalidPayload);
411 }
412 }
413 state.request = Some(record);
414 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
415 }
416 "Approve" => {
417 let payload =
418 ApprovePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
419 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
420 if payload.request_id != request.request_id
421 || state.ballots.contains_key(&env.sender)
422 {
423 return Err(MacpError::InvalidPayload);
424 }
425 state.ballots.insert(
426 env.sender.clone(),
427 BallotRecord {
428 request_id: payload.request_id,
429 choice: BallotChoice::Approve,
430 sender: env.sender.clone(),
431 reason: payload.reason,
432 },
433 );
434 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
435 }
436 "Reject" => {
437 let payload =
438 RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
439 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
440 if payload.request_id != request.request_id
441 || state.ballots.contains_key(&env.sender)
442 {
443 return Err(MacpError::InvalidPayload);
444 }
445 state.ballots.insert(
446 env.sender.clone(),
447 BallotRecord {
448 request_id: payload.request_id,
449 choice: BallotChoice::Reject,
450 sender: env.sender.clone(),
451 reason: payload.reason,
452 },
453 );
454 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
455 }
456 "Abstain" => {
457 let payload =
458 AbstainPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
459 let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
460 if payload.request_id != request.request_id
461 || state.ballots.contains_key(&env.sender)
462 {
463 return Err(MacpError::InvalidPayload);
464 }
465 state.ballots.insert(
466 env.sender.clone(),
467 BallotRecord {
468 request_id: payload.request_id,
469 choice: BallotChoice::Abstain,
470 sender: env.sender.clone(),
471 reason: payload.reason,
472 },
473 );
474 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
475 }
476 "Commitment" => {
477 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
478 if !Self::commitment_ready(session, &state) {
479 return Err(MacpError::InvalidPayload);
480 }
481 let approve_count = state
484 .ballots
485 .values()
486 .filter(|b| b.choice == BallotChoice::Approve)
487 .count();
488 let reject_count = state
489 .ballots
490 .values()
491 .filter(|b| b.choice == BallotChoice::Reject)
492 .count();
493 let abstain_count = state
494 .ballots
495 .values()
496 .filter(|b| b.choice == BallotChoice::Abstain)
497 .count();
498 enforce_commitment_policy(
499 session,
500 macp_core::policy::CommitmentMode::Quorum {
501 approve_count,
502 reject_count,
503 abstain_count,
504 },
505 commitment.outcome_positive,
506 &*self.evaluator,
507 )?;
508 Ok(ModeResponse::PersistAndResolve {
509 state: Self::encode_state(&state),
510 resolution: env.payload.clone(),
511 })
512 }
513 _ => Err(MacpError::InvalidPayload),
514 }
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use macp_core::session::Session;
522 use macp_pb::pb::CommitmentPayload;
523
524 fn base_session() -> Session {
525 Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
526 .ttl_ms(60_000)
527 .participants(vec!["alice".into(), "bob".into(), "carol".into()])
528 .mode_version("1.0.0")
529 .configuration_version("config")
530 .policy_version("policy")
531 .build()
532 }
533
534 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
535 Envelope {
536 macp_version: "1.0".into(),
537 mode: "macp.mode.quorum.v1".into(),
538 message_type: message_type.into(),
539 message_id: format!("{}-{}", sender, message_type),
540 session_id: "s1".into(),
541 sender: sender.into(),
542 timestamp_unix_ms: 0,
543 payload,
544 }
545 }
546
547 fn commitment_payload() -> Vec<u8> {
548 commitment("quorum.approved", true)
549 }
550
551 fn commitment(action: &str, outcome_positive: bool) -> Vec<u8> {
552 CommitmentPayload {
553 commitment_id: "c1".into(),
554 action: action.into(),
555 authority_scope: "deploy".into(),
556 reason: "threshold met".into(),
557 mode_version: "1.0.0".into(),
558 policy_version: "policy".into(),
559 configuration_version: "config".into(),
560 outcome_positive,
561 supersedes: None,
562 }
563 .encode_to_vec()
564 }
565
566 fn apply(session: &mut Session, result: ModeResponse) {
567 match result {
568 ModeResponse::PersistState(data) => session.mode_state = data,
569 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
570 _ => {}
571 }
572 }
573
574 fn make_approval_request(request_id: &str, required: u32) -> Vec<u8> {
575 ApprovalRequestPayload {
576 request_id: request_id.into(),
577 action: "deploy.production".into(),
578 summary: "Deploy v2".into(),
579 details: vec![],
580 required_approvals: required,
581 }
582 .encode_to_vec()
583 }
584
585 fn make_approve(request_id: &str, reason: &str) -> Vec<u8> {
586 ApprovePayload {
587 request_id: request_id.into(),
588 reason: reason.into(),
589 }
590 .encode_to_vec()
591 }
592
593 fn make_reject(request_id: &str, reason: &str) -> Vec<u8> {
594 RejectPayload {
595 request_id: request_id.into(),
596 reason: reason.into(),
597 }
598 .encode_to_vec()
599 }
600
601 fn make_abstain(request_id: &str, reason: &str) -> Vec<u8> {
602 AbstainPayload {
603 request_id: request_id.into(),
604 reason: reason.into(),
605 }
606 .encode_to_vec()
607 }
608
609 #[test]
612 fn session_start_initializes_state() {
613 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
614 let session = base_session();
615 let result = mode
616 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
617 .unwrap();
618 match result {
619 ModeResponse::PersistState(data) => {
620 let state: QuorumState = serde_json::from_slice(&data).unwrap();
621 assert!(state.request.is_none());
622 assert!(state.ballots.is_empty());
623 }
624 _ => panic!("Expected PersistState"),
625 }
626 }
627
628 #[test]
629 fn session_start_requires_participants() {
630 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
631 let mut session = base_session();
632 session.participants.clear();
633 let err = mode
634 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
635 .unwrap_err();
636 assert_eq!(err.to_string(), "InvalidPayload");
637 }
638
639 #[test]
642 fn approval_request_from_coordinator() {
643 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
644 let mut session = base_session();
645 let result = mode
646 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
647 .unwrap();
648 apply(&mut session, result);
649 let result = mode
650 .on_message(
651 &session,
652 &env(
653 "coordinator",
654 "ApprovalRequest",
655 make_approval_request("r1", 2),
656 ),
657 )
658 .unwrap();
659 match result {
660 ModeResponse::PersistState(data) => {
661 let state: QuorumState = serde_json::from_slice(&data).unwrap();
662 assert!(state.request.is_some());
663 assert_eq!(state.request.unwrap().required_approvals, 2);
664 }
665 _ => panic!("Expected PersistState"),
666 }
667 }
668
669 #[test]
670 fn duplicate_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 result = mode
678 .on_message(
679 &session,
680 &env(
681 "coordinator",
682 "ApprovalRequest",
683 make_approval_request("r1", 2),
684 ),
685 )
686 .unwrap();
687 apply(&mut session, result);
688 let err = mode
689 .on_message(
690 &session,
691 &env(
692 "coordinator",
693 "ApprovalRequest",
694 make_approval_request("r2", 1),
695 ),
696 )
697 .unwrap_err();
698 assert_eq!(err.to_string(), "InvalidPayload");
699 }
700
701 #[test]
702 fn required_approvals_exceeds_participants_rejected() {
703 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
704 let mut session = base_session();
705 let result = mode
706 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
707 .unwrap();
708 apply(&mut session, result);
709 let err = mode
710 .on_message(
711 &session,
712 &env(
713 "coordinator",
714 "ApprovalRequest",
715 make_approval_request("r1", 4),
716 ),
717 )
718 .unwrap_err();
719 assert_eq!(err.to_string(), "InvalidPayload");
720 }
721
722 #[test]
723 fn required_approvals_zero_rejected() {
724 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
725 let mut session = base_session();
726 let result = mode
727 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
728 .unwrap();
729 apply(&mut session, result);
730 let err = mode
731 .on_message(
732 &session,
733 &env(
734 "coordinator",
735 "ApprovalRequest",
736 make_approval_request("r1", 0),
737 ),
738 )
739 .unwrap_err();
740 assert_eq!(err.to_string(), "InvalidPayload");
741 }
742
743 #[test]
744 fn non_coordinator_approval_request_rejected() {
745 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
746 let mut session = base_session();
747 let result = mode
748 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
749 .unwrap();
750 apply(&mut session, result);
751 let err = mode
752 .on_message(
753 &session,
754 &env("alice", "ApprovalRequest", make_approval_request("r1", 2)),
755 )
756 .unwrap_err();
757 assert_eq!(err.to_string(), "Forbidden");
758 }
759
760 #[test]
763 fn participant_can_approve() {
764 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
765 let mut session = base_session();
766 let result = mode
767 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
768 .unwrap();
769 apply(&mut session, result);
770 let result = mode
771 .on_message(
772 &session,
773 &env(
774 "coordinator",
775 "ApprovalRequest",
776 make_approval_request("r1", 2),
777 ),
778 )
779 .unwrap();
780 apply(&mut session, result);
781 let result = mode
782 .on_message(
783 &session,
784 &env("alice", "Approve", make_approve("r1", "looks good")),
785 )
786 .unwrap();
787 match result {
788 ModeResponse::PersistState(data) => {
789 let state: QuorumState = serde_json::from_slice(&data).unwrap();
790 assert!(state.ballots.contains_key("alice"));
791 assert_eq!(state.ballots["alice"].choice, BallotChoice::Approve);
792 }
793 _ => panic!("Expected PersistState"),
794 }
795 }
796
797 #[test]
798 fn participant_can_reject() {
799 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
800 let mut session = base_session();
801 let result = mode
802 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
803 .unwrap();
804 apply(&mut session, result);
805 let result = mode
806 .on_message(
807 &session,
808 &env(
809 "coordinator",
810 "ApprovalRequest",
811 make_approval_request("r1", 2),
812 ),
813 )
814 .unwrap();
815 apply(&mut session, result);
816 let result = mode
817 .on_message(
818 &session,
819 &env("alice", "Reject", make_reject("r1", "not ready")),
820 )
821 .unwrap();
822 match result {
823 ModeResponse::PersistState(data) => {
824 let state: QuorumState = serde_json::from_slice(&data).unwrap();
825 assert_eq!(state.ballots["alice"].choice, BallotChoice::Reject);
826 }
827 _ => panic!("Expected PersistState"),
828 }
829 }
830
831 #[test]
832 fn participant_can_abstain() {
833 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
834 let mut session = base_session();
835 let result = mode
836 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
837 .unwrap();
838 apply(&mut session, result);
839 let result = mode
840 .on_message(
841 &session,
842 &env(
843 "coordinator",
844 "ApprovalRequest",
845 make_approval_request("r1", 2),
846 ),
847 )
848 .unwrap();
849 apply(&mut session, result);
850 let result = mode
851 .on_message(
852 &session,
853 &env("alice", "Abstain", make_abstain("r1", "no opinion")),
854 )
855 .unwrap();
856 match result {
857 ModeResponse::PersistState(data) => {
858 let state: QuorumState = serde_json::from_slice(&data).unwrap();
859 assert_eq!(state.ballots["alice"].choice, BallotChoice::Abstain);
860 }
861 _ => panic!("Expected PersistState"),
862 }
863 }
864
865 #[test]
866 fn duplicate_ballot_rejected() {
867 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
868 let mut session = base_session();
869 let result = mode
870 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
871 .unwrap();
872 apply(&mut session, result);
873 let result = mode
874 .on_message(
875 &session,
876 &env(
877 "coordinator",
878 "ApprovalRequest",
879 make_approval_request("r1", 2),
880 ),
881 )
882 .unwrap();
883 apply(&mut session, result);
884 let result = mode
885 .on_message(
886 &session,
887 &env("alice", "Approve", make_approve("r1", "yes")),
888 )
889 .unwrap();
890 apply(&mut session, result);
891 let err = mode
892 .on_message(
893 &session,
894 &env("alice", "Approve", make_approve("r1", "again")),
895 )
896 .unwrap_err();
897 assert_eq!(err.to_string(), "InvalidPayload");
898 }
899
900 #[test]
901 fn ballot_before_request_rejected() {
902 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
903 let mut session = base_session();
904 let result = mode
905 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
906 .unwrap();
907 apply(&mut session, result);
908 let err = mode
909 .on_message(
910 &session,
911 &env("alice", "Approve", make_approve("r1", "premature")),
912 )
913 .unwrap_err();
914 assert_eq!(err.to_string(), "InvalidPayload");
915 }
916
917 #[test]
918 fn wrong_request_id_rejected() {
919 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
920 let mut session = base_session();
921 let result = mode
922 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
923 .unwrap();
924 apply(&mut session, result);
925 let result = mode
926 .on_message(
927 &session,
928 &env(
929 "coordinator",
930 "ApprovalRequest",
931 make_approval_request("r1", 2),
932 ),
933 )
934 .unwrap();
935 apply(&mut session, result);
936 let err = mode
937 .on_message(
938 &session,
939 &env("alice", "Approve", make_approve("r2", "wrong id")),
940 )
941 .unwrap_err();
942 assert_eq!(err.to_string(), "InvalidPayload");
943 }
944
945 #[test]
948 fn commitment_when_threshold_reached() {
949 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
950 let mut session = base_session();
951 let result = mode
952 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
953 .unwrap();
954 apply(&mut session, result);
955 let result = mode
956 .on_message(
957 &session,
958 &env(
959 "coordinator",
960 "ApprovalRequest",
961 make_approval_request("r1", 2),
962 ),
963 )
964 .unwrap();
965 apply(&mut session, result);
966 let result = mode
967 .on_message(
968 &session,
969 &env("alice", "Approve", make_approve("r1", "yes")),
970 )
971 .unwrap();
972 apply(&mut session, result);
973 let result = mode
974 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
975 .unwrap();
976 apply(&mut session, result);
977 let result = mode
978 .on_message(
979 &session,
980 &env("coordinator", "Commitment", commitment_payload()),
981 )
982 .unwrap();
983 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
984 }
985
986 #[test]
987 fn commitment_when_threshold_unreachable() {
988 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
989 let mut session = base_session();
990 let result = mode
991 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
992 .unwrap();
993 apply(&mut session, result);
994 let result = mode
995 .on_message(
996 &session,
997 &env(
998 "coordinator",
999 "ApprovalRequest",
1000 make_approval_request("r1", 3),
1001 ),
1002 )
1003 .unwrap();
1004 apply(&mut session, result);
1005 let result = mode
1007 .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
1008 .unwrap();
1009 apply(&mut session, result);
1010 let result = mode
1012 .on_message(
1013 &session,
1014 &env("coordinator", "Commitment", commitment_payload()),
1015 )
1016 .unwrap();
1017 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1018 }
1019
1020 #[test]
1021 fn commitment_before_threshold_rejected() {
1022 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1023 let mut session = base_session();
1024 let result = mode
1025 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1026 .unwrap();
1027 apply(&mut session, result);
1028 let result = mode
1029 .on_message(
1030 &session,
1031 &env(
1032 "coordinator",
1033 "ApprovalRequest",
1034 make_approval_request("r1", 2),
1035 ),
1036 )
1037 .unwrap();
1038 apply(&mut session, result);
1039 let result = mode
1040 .on_message(
1041 &session,
1042 &env("alice", "Approve", make_approve("r1", "yes")),
1043 )
1044 .unwrap();
1045 apply(&mut session, result);
1046 let err = mode
1048 .on_message(
1049 &session,
1050 &env("coordinator", "Commitment", commitment_payload()),
1051 )
1052 .unwrap_err();
1053 assert_eq!(err.to_string(), "InvalidPayload");
1054 }
1055
1056 #[test]
1057 fn non_coordinator_commitment_rejected() {
1058 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1059 let mut session = base_session();
1060 let result = mode
1061 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1062 .unwrap();
1063 apply(&mut session, result);
1064 let result = mode
1065 .on_message(
1066 &session,
1067 &env(
1068 "coordinator",
1069 "ApprovalRequest",
1070 make_approval_request("r1", 2),
1071 ),
1072 )
1073 .unwrap();
1074 apply(&mut session, result);
1075 let result = mode
1076 .on_message(
1077 &session,
1078 &env("alice", "Approve", make_approve("r1", "yes")),
1079 )
1080 .unwrap();
1081 apply(&mut session, result);
1082 let result = mode
1083 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1084 .unwrap();
1085 apply(&mut session, result);
1086 let commit_env = env("alice", "Commitment", commitment_payload());
1087 let err = mode.authorize_sender(&session, &commit_env).unwrap_err();
1088 assert_eq!(err.to_string(), "Forbidden");
1089 }
1090
1091 #[test]
1094 fn full_quorum_approve_lifecycle() {
1095 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1096 let mut session = base_session();
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 let result = mode
1113 .on_message(
1114 &session,
1115 &env("alice", "Approve", make_approve("r1", "green")),
1116 )
1117 .unwrap();
1118 apply(&mut session, result);
1119 let result = mode
1120 .on_message(
1121 &session,
1122 &env("bob", "Approve", make_approve("r1", "ready")),
1123 )
1124 .unwrap();
1125 apply(&mut session, result);
1126 let result = mode
1127 .on_message(
1128 &session,
1129 &env("coordinator", "Commitment", commitment_payload()),
1130 )
1131 .unwrap();
1132 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1133 }
1134
1135 #[test]
1136 fn full_quorum_reject_lifecycle() {
1137 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1138 let mut session = base_session();
1139 let result = mode
1140 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1141 .unwrap();
1142 apply(&mut session, result);
1143 let result = mode
1144 .on_message(
1145 &session,
1146 &env(
1147 "coordinator",
1148 "ApprovalRequest",
1149 make_approval_request("r1", 3),
1150 ),
1151 )
1152 .unwrap();
1153 apply(&mut session, result);
1154 let result = mode
1155 .on_message(
1156 &session,
1157 &env("alice", "Reject", make_reject("r1", "not ready")),
1158 )
1159 .unwrap();
1160 apply(&mut session, result);
1161 let result = mode
1163 .on_message(
1164 &session,
1165 &env("coordinator", "Commitment", commitment_payload()),
1166 )
1167 .unwrap();
1168 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1169 }
1170
1171 #[test]
1174 fn commitment_version_mismatch_rejected() {
1175 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1176 let mut session = base_session();
1177 let result = mode
1178 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1179 .unwrap();
1180 apply(&mut session, result);
1181 let result = mode
1182 .on_message(
1183 &session,
1184 &env(
1185 "coordinator",
1186 "ApprovalRequest",
1187 make_approval_request("r1", 2),
1188 ),
1189 )
1190 .unwrap();
1191 apply(&mut session, result);
1192 let result = mode
1193 .on_message(
1194 &session,
1195 &env("alice", "Approve", make_approve("r1", "yes")),
1196 )
1197 .unwrap();
1198 apply(&mut session, result);
1199 let result = mode
1200 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1201 .unwrap();
1202 apply(&mut session, result);
1203 let bad_commitment = CommitmentPayload {
1204 commitment_id: "c1".into(),
1205 action: "quorum.approved".into(),
1206 authority_scope: "deploy".into(),
1207 reason: "threshold met".into(),
1208 mode_version: "wrong".into(),
1209 policy_version: "policy".into(),
1210 configuration_version: "config".into(),
1211 outcome_positive: true,
1212 supersedes: None,
1213 }
1214 .encode_to_vec();
1215 let err = mode
1216 .on_message(&session, &env("coordinator", "Commitment", bad_commitment))
1217 .unwrap_err();
1218 assert_eq!(err.to_string(), "InvalidPayload");
1219 }
1220
1221 #[test]
1224 fn unknown_message_type_rejected() {
1225 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1226 let mut session = base_session();
1227 let result = mode
1228 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1229 .unwrap();
1230 apply(&mut session, result);
1231 let err = mode
1232 .on_message(&session, &env("alice", "CustomType", vec![]))
1233 .unwrap_err();
1234 assert_eq!(err.to_string(), "InvalidPayload");
1235 }
1236
1237 #[test]
1240 fn policy_denies_commitment_when_quorum_not_met_due_to_abstentions() {
1241 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1242 let mut session = base_session();
1243 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1245 policy_id: "test-strict-quorum".into(),
1246 mode: "macp.mode.quorum.v1".into(),
1247 description: "strict quorum".into(),
1248 rules: serde_json::json!({
1249 "threshold": { "type": "n_of_m", "value": 3 },
1250 "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" }
1251 }),
1252 schema_version: 1,
1253 });
1254 let result = mode
1255 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1256 .unwrap();
1257 apply(&mut session, result);
1258 let result = mode
1259 .on_message(
1260 &session,
1261 &env(
1262 "coordinator",
1263 "ApprovalRequest",
1264 make_approval_request("r1", 2),
1265 ),
1266 )
1267 .unwrap();
1268 apply(&mut session, result);
1269 let result = mode
1270 .on_message(
1271 &session,
1272 &env("alice", "Approve", make_approve("r1", "yes")),
1273 )
1274 .unwrap();
1275 apply(&mut session, result);
1276 let result = mode
1277 .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1278 .unwrap();
1279 apply(&mut session, result);
1280 let result = mode
1282 .on_message(
1283 &session,
1284 &env("carol", "Abstain", make_abstain("r1", "no opinion")),
1285 )
1286 .unwrap();
1287 apply(&mut session, result);
1288 let err = mode
1290 .on_message(
1291 &session,
1292 &env("coordinator", "Commitment", commitment_payload()),
1293 )
1294 .unwrap_err();
1295 assert_eq!(err.to_string(), "PolicyDenied");
1296 }
1297
1298 #[test]
1301 fn negative_outcome_quorum_rejected() {
1302 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1303 let mut session = base_session();
1304 let result = mode
1306 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1307 .unwrap();
1308 apply(&mut session, result);
1309 let result = mode
1310 .on_message(
1311 &session,
1312 &env(
1313 "coordinator",
1314 "ApprovalRequest",
1315 make_approval_request("r1", 2),
1316 ),
1317 )
1318 .unwrap();
1319 apply(&mut session, result);
1320 let result = mode
1322 .on_message(
1323 &session,
1324 &env("alice", "Reject", make_reject("r1", "not ready")),
1325 )
1326 .unwrap();
1327 apply(&mut session, result);
1328 let result = mode
1329 .on_message(
1330 &session,
1331 &env("bob", "Reject", make_reject("r1", "disagree")),
1332 )
1333 .unwrap();
1334 apply(&mut session, result);
1335 let negative_commitment = commitment("quorum.rejected", false);
1338 let result = mode
1339 .on_message(
1340 &session,
1341 &env("coordinator", "Commitment", negative_commitment),
1342 )
1343 .unwrap();
1344 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1345 }
1346
1347 #[test]
1350 fn all_participants_abstain_allows_negative_commitment() {
1351 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1352 let mut session = base_session();
1353 let result = mode
1355 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1356 .unwrap();
1357 apply(&mut session, result);
1358 let result = mode
1359 .on_message(
1360 &session,
1361 &env(
1362 "coordinator",
1363 "ApprovalRequest",
1364 make_approval_request("r1", 2),
1365 ),
1366 )
1367 .unwrap();
1368 apply(&mut session, result);
1369 let result = mode
1371 .on_message(
1372 &session,
1373 &env("alice", "Abstain", make_abstain("r1", "neutral")),
1374 )
1375 .unwrap();
1376 apply(&mut session, result);
1377 let result = mode
1378 .on_message(
1379 &session,
1380 &env("bob", "Abstain", make_abstain("r1", "neutral")),
1381 )
1382 .unwrap();
1383 apply(&mut session, result);
1384 let result = mode
1385 .on_message(
1386 &session,
1387 &env("carol", "Abstain", make_abstain("r1", "neutral")),
1388 )
1389 .unwrap();
1390 apply(&mut session, result);
1391 let negative_commitment = CommitmentPayload {
1394 commitment_id: "c1".into(),
1395 action: "quorum.rejected".into(),
1396 authority_scope: "deploy".into(),
1397 reason: "all abstained".into(),
1398 mode_version: "1.0.0".into(),
1399 policy_version: "policy".into(),
1400 configuration_version: "config".into(),
1401 outcome_positive: false,
1402 supersedes: None,
1403 }
1404 .encode_to_vec();
1405 let result = mode
1406 .on_message(
1407 &session,
1408 &env("coordinator", "Commitment", negative_commitment),
1409 )
1410 .unwrap();
1411 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1412 }
1413
1414 #[test]
1417 fn initiator_not_in_participants_cannot_cast_ballot() {
1418 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1419 let mut session = base_session();
1420 let result = mode
1423 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1424 .unwrap();
1425 apply(&mut session, result);
1426 let result = mode
1427 .on_message(
1428 &session,
1429 &env(
1430 "coordinator",
1431 "ApprovalRequest",
1432 make_approval_request("r1", 2),
1433 ),
1434 )
1435 .unwrap();
1436 apply(&mut session, result);
1437 let approve_env = env("coordinator", "Approve", make_approve("r1", "yes"));
1440 let err = mode.authorize_sender(&session, &approve_env).unwrap_err();
1441 assert_eq!(err.to_string(), "Forbidden");
1442 }
1443
1444 #[test]
1447 fn policy_threshold_overrides_required_approvals() {
1448 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1449 let mut session = base_session();
1450 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1452 policy_id: "threshold-override".into(),
1453 mode: "macp.mode.quorum.v1".into(),
1454 description: "low threshold".into(),
1455 rules: serde_json::json!({
1456 "threshold": { "type": "n_of_m", "value": 1.0 }
1457 }),
1458 schema_version: 1,
1459 });
1460 let result = mode
1461 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1462 .unwrap();
1463 apply(&mut session, result);
1464 let result = mode
1465 .on_message(
1466 &session,
1467 &env(
1468 "coordinator",
1469 "ApprovalRequest",
1470 make_approval_request("r1", 3), ),
1472 )
1473 .unwrap();
1474 apply(&mut session, result);
1475 let result = mode
1477 .on_message(
1478 &session,
1479 &env("alice", "Approve", make_approve("r1", "yes")),
1480 )
1481 .unwrap();
1482 apply(&mut session, result);
1483 let commit = mode
1485 .on_message(
1486 &session,
1487 &env("coordinator", "Commitment", commitment_payload()),
1488 )
1489 .unwrap();
1490 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1491 }
1492
1493 #[test]
1494 fn policy_percentage_threshold() {
1495 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1496 let mut session = base_session();
1497 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1499 policy_id: "pct-override".into(),
1500 mode: "macp.mode.quorum.v1".into(),
1501 description: "percentage threshold".into(),
1502 rules: serde_json::json!({
1503 "threshold": { "type": "percentage", "value": 50.0 }
1504 }),
1505 schema_version: 1,
1506 });
1507 let result = mode
1508 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1509 .unwrap();
1510 apply(&mut session, result);
1511 let result = mode
1512 .on_message(
1513 &session,
1514 &env(
1515 "coordinator",
1516 "ApprovalRequest",
1517 make_approval_request("r1", 3),
1518 ),
1519 )
1520 .unwrap();
1521 apply(&mut session, result);
1522 let result = mode
1524 .on_message(
1525 &session,
1526 &env("alice", "Approve", make_approve("r1", "yes")),
1527 )
1528 .unwrap();
1529 apply(&mut session, result);
1530 let err = mode
1531 .on_message(
1532 &session,
1533 &env("coordinator", "Commitment", commitment_payload()),
1534 )
1535 .unwrap_err();
1536 assert_eq!(err.to_string(), "InvalidPayload");
1537 let result = mode
1539 .on_message(
1540 &session,
1541 &env("bob", "Approve", make_approve("r1", "agreed")),
1542 )
1543 .unwrap();
1544 apply(&mut session, result);
1545 let commit = mode
1546 .on_message(
1547 &session,
1548 &env("coordinator", "Commitment", commitment_payload()),
1549 )
1550 .unwrap();
1551 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1552 }
1553
1554 #[test]
1555 fn all_abstain_eligible_for_negative_commitment() {
1556 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1559 let mut session = base_session();
1560 let result = mode
1561 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1562 .unwrap();
1563 apply(&mut session, result);
1564 let result = mode
1565 .on_message(
1566 &session,
1567 &env(
1568 "coordinator",
1569 "ApprovalRequest",
1570 make_approval_request("r1", 2),
1571 ),
1572 )
1573 .unwrap();
1574 apply(&mut session, result);
1575 for sender in &["alice", "bob", "carol"] {
1577 let result = mode
1578 .on_message(
1579 &session,
1580 &env(sender, "Abstain", make_abstain("r1", "neutral")),
1581 )
1582 .unwrap();
1583 apply(&mut session, result);
1584 }
1585 let commit = mode
1587 .on_message(
1588 &session,
1589 &env(
1590 "coordinator",
1591 "Commitment",
1592 commitment("quorum.rejected", false),
1593 ),
1594 )
1595 .unwrap();
1596 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1597 }
1598
1599 fn quorum_policy(rules: serde_json::Value) -> macp_core::policy::PolicyDefinition {
1602 macp_core::policy::PolicyDefinition {
1603 policy_id: "threshold-parity".into(),
1604 mode: "macp.mode.quorum.v1".into(),
1605 description: "threshold parity fixture".into(),
1606 rules,
1607 schema_version: 1,
1608 }
1609 }
1610
1611 fn session_with(participants: usize, rules: serde_json::Value) -> Session {
1612 let names = ["alice", "bob", "carol", "dave", "erin"];
1613 let mut session = Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
1614 .ttl_ms(60_000)
1615 .participants(
1616 names[..participants]
1617 .iter()
1618 .map(|s| s.to_string())
1619 .collect(),
1620 )
1621 .mode_version("1.0.0")
1622 .configuration_version("config")
1623 .policy_version("policy")
1624 .build();
1625 session.policy_definition = Some(quorum_policy(rules));
1626 session
1627 }
1628
1629 fn matrix_request() -> ApprovalRequestRecord {
1633 ApprovalRequestRecord {
1634 request_id: "r1".into(),
1635 action: "deploy.production".into(),
1636 summary: "Deploy v2".into(),
1637 details: vec![],
1638 required_approvals: 99,
1639 requested_by: "coordinator".into(),
1640 }
1641 }
1642
1643 fn as_option(threshold: ApprovalThreshold) -> Option<u32> {
1646 match threshold {
1647 ApprovalThreshold::Approvals(required) => Some(required),
1648 ApprovalThreshold::Unsatisfiable => None,
1649 }
1650 }
1651
1652 fn mode_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1654 let session = session_with(participants, rules);
1655 as_option(QuorumMode::effective_threshold(&session, &matrix_request()))
1656 }
1657
1658 fn session_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1666 let mut session = session_with(participants, rules);
1667 session.mode_state = QuorumMode::encode_state(&QuorumState {
1668 request: Some(matrix_request()),
1669 ballots: BTreeMap::new(),
1670 });
1671 as_option(
1672 QuorumMode::effective_threshold_for_session(&session)
1673 .expect("state seated by encode_state decodes")
1674 .expect("a request was seated"),
1675 )
1676 }
1677
1678 fn evaluator_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1683 let policy = quorum_policy(rules);
1684 (0u32..=64).find(|approve| {
1685 matches!(
1686 macp_policy::evaluator::evaluate_quorum_commitment_outcome(
1687 &policy,
1688 *approve as usize,
1689 0,
1690 0,
1691 participants,
1692 true,
1693 ),
1694 macp_core::policy::PolicyDecision::Allow { .. }
1695 )
1696 })
1697 }
1698
1699 #[test]
1700 fn fractional_threshold_ceils_to_one_in_both_layers() {
1701 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } });
1704 assert_eq!(mode_required(3, rules.clone()), Some(1));
1705 assert_eq!(evaluator_required(3, rules), Some(1));
1706 }
1707
1708 #[test]
1709 fn threshold_is_floored_at_one_so_a_zero_bar_is_unreachable() {
1710 for value in [0.000_001, 0.1, 0.49, 0.5, 0.99] {
1712 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": value } });
1713 assert_eq!(mode_required(3, rules.clone()), Some(1), "value {value}");
1714 assert_eq!(evaluator_required(3, rules), Some(1), "value {value}");
1715 }
1716 }
1717
1718 #[test]
1719 fn zero_ballot_decline_is_refused_under_a_fractional_threshold() {
1720 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1723 let mut session = session_with(
1724 3,
1725 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } }),
1726 );
1727 let result = mode
1728 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1729 .unwrap();
1730 apply(&mut session, result);
1731 let result = mode
1732 .on_message(
1733 &session,
1734 &env(
1735 "coordinator",
1736 "ApprovalRequest",
1737 make_approval_request("r1", 3),
1738 ),
1739 )
1740 .unwrap();
1741 apply(&mut session, result);
1742 let err = mode
1743 .on_message(
1744 &session,
1745 &env(
1746 "coordinator",
1747 "Commitment",
1748 commitment("quorum.rejected", false),
1749 ),
1750 )
1751 .unwrap_err();
1752 assert_eq!(err.to_string(), "InvalidPayload");
1753 let err = mode
1755 .on_message(
1756 &session,
1757 &env("coordinator", "Commitment", commitment_payload()),
1758 )
1759 .unwrap_err();
1760 assert_eq!(err.to_string(), "InvalidPayload");
1761 let result = mode
1763 .on_message(
1764 &session,
1765 &env("alice", "Approve", make_approve("r1", "yes")),
1766 )
1767 .unwrap();
1768 apply(&mut session, result);
1769 let commit = mode
1770 .on_message(
1771 &session,
1772 &env("coordinator", "Commitment", commitment_payload()),
1773 )
1774 .unwrap();
1775 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1776 }
1777
1778 #[test]
1779 fn weighted_threshold_is_unsatisfiable_not_a_raw_approval_count() {
1780 let rules = serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } });
1785 assert_eq!(mode_required(3, rules.clone()), None);
1786 assert_eq!(evaluator_required(3, rules.clone()), None);
1787
1788 let unknown = serde_json::json!({ "threshold": { "type": "two_thirds", "value": 2 } });
1790 assert_eq!(mode_required(3, unknown.clone()), None);
1791 assert_eq!(evaluator_required(3, unknown), None);
1792
1793 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1796 let mut session = session_with(3, rules);
1797 let result = mode
1798 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1799 .unwrap();
1800 apply(&mut session, result);
1801 let err = mode
1802 .on_message(
1803 &session,
1804 &env(
1805 "coordinator",
1806 "ApprovalRequest",
1807 make_approval_request("r1", 2),
1808 ),
1809 )
1810 .unwrap_err();
1811 assert_eq!(err.to_string(), "InvalidPayload");
1812 }
1813
1814 #[test]
1815 fn mode_and_evaluator_agree_across_the_threshold_matrix() {
1816 let cases: [(&str, [f64; 5]); 3] = [
1828 ("n_of_m", [0.5, 1.0, 2.0, 3.0, 5.0]),
1829 ("count", [0.5, 1.0, 2.0, 3.0, 5.0]),
1830 ("percentage", [0.5, 33.0, 50.0, 100.0, 150.0]),
1832 ];
1833 for (threshold_type, values) in cases {
1834 for value in values {
1835 for participants in 1..=3usize {
1836 let rules = serde_json::json!({
1837 "threshold": { "type": threshold_type, "value": value }
1838 });
1839 let mode = mode_required(participants, rules.clone());
1840 let session = session_required(participants, rules.clone());
1841 let evaluator = evaluator_required(participants, rules);
1842 assert_eq!(
1843 mode, evaluator,
1844 "type={threshold_type} value={value} participants={participants}"
1845 );
1846 assert_eq!(
1850 session, evaluator,
1851 "session-level accessor: type={threshold_type} value={value} \
1852 participants={participants}"
1853 );
1854 assert_ne!(
1855 mode,
1856 Some(0),
1857 "type={threshold_type} value={value} participants={participants}: \
1858 a bar of 0 is met before any ballot is cast"
1859 );
1860 }
1861 }
1862 }
1863 }
1864
1865 #[test]
1866 fn session_threshold_separates_no_request_from_unsatisfiable() {
1867 let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } });
1873
1874 let mut session = session_with(3, rules.clone());
1877 assert_eq!(
1878 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1879 None
1880 );
1881 session.mode_state = QuorumMode::encode_state(&QuorumState::default());
1882 assert_eq!(
1883 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1884 None
1885 );
1886
1887 session.mode_state = QuorumMode::encode_state(&QuorumState {
1889 request: Some(matrix_request()),
1890 ballots: BTreeMap::new(),
1891 });
1892 assert_eq!(
1893 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1894 Some(ApprovalThreshold::Approvals(2))
1895 );
1896
1897 session.policy_definition = Some(quorum_policy(
1900 serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } }),
1901 ));
1902 assert_eq!(
1903 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1904 Some(ApprovalThreshold::Unsatisfiable)
1905 );
1906
1907 session.mode_state = b"{not-quorum-state".to_vec();
1910 assert_eq!(
1911 QuorumMode::effective_threshold_for_session(&session)
1912 .unwrap_err()
1913 .to_string(),
1914 "InvalidModeState"
1915 );
1916 }
1917
1918 #[test]
1919 fn session_threshold_falls_back_to_the_requests_own_required_approvals() {
1920 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1926 let mut session = base_session();
1927 session.policy_definition = None;
1928 let result = mode
1929 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1930 .unwrap();
1931 apply(&mut session, result);
1932 assert_eq!(
1933 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1934 None
1935 );
1936 let result = mode
1937 .on_message(
1938 &session,
1939 &env(
1940 "coordinator",
1941 "ApprovalRequest",
1942 make_approval_request("r1", 2),
1943 ),
1944 )
1945 .unwrap();
1946 apply(&mut session, result);
1947 assert_eq!(
1948 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1949 Some(ApprovalThreshold::Approvals(2))
1950 );
1951 session.policy_definition = Some(quorum_policy(
1954 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0 } }),
1955 ));
1956 assert_eq!(
1957 QuorumMode::effective_threshold_for_session(&session).unwrap(),
1958 Some(ApprovalThreshold::Approvals(2))
1959 );
1960 }
1961
1962 #[test]
1963 fn over_participant_policy_threshold_refuses_the_approval_request() {
1964 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1970 let mut session = session_with(
1971 3,
1972 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
1973 );
1974 let result = mode
1975 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1976 .unwrap();
1977 apply(&mut session, result);
1978 let err = mode
1979 .on_message(
1980 &session,
1981 &env(
1982 "coordinator",
1983 "ApprovalRequest",
1984 make_approval_request("r1", 3),
1985 ),
1986 )
1987 .unwrap_err();
1988 assert_eq!(err.to_string(), "InvalidPayload");
1989 session.policy_definition = Some(quorum_policy(
1991 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
1992 ));
1993 assert!(mode
1994 .on_message(
1995 &session,
1996 &env(
1997 "coordinator",
1998 "ApprovalRequest",
1999 make_approval_request("r1", 3),
2000 ),
2001 )
2002 .is_ok());
2003 }
2004
2005 #[test]
2006 fn zero_ballot_decline_is_refused_when_policy_rebinds_over_the_participant_pool() {
2007 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2013 let mut session = session_with(
2014 3,
2015 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
2016 );
2017 let result = mode
2018 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
2019 .unwrap();
2020 apply(&mut session, result);
2021 let result = mode
2022 .on_message(
2023 &session,
2024 &env(
2025 "coordinator",
2026 "ApprovalRequest",
2027 make_approval_request("r1", 3),
2028 ),
2029 )
2030 .unwrap();
2031 apply(&mut session, result);
2032 session.policy_definition = Some(quorum_policy(
2034 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
2035 ));
2036 let err = mode
2037 .on_message(
2038 &session,
2039 &env(
2040 "coordinator",
2041 "Commitment",
2042 commitment("quorum.rejected", false),
2043 ),
2044 )
2045 .unwrap_err();
2046 assert_eq!(err.to_string(), "InvalidPayload");
2047 }
2048
2049 #[test]
2050 fn genuine_unreachable_threshold_still_permits_a_negative_commitment() {
2051 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2056 let mut session = session_with(
2057 3,
2058 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } }),
2059 );
2060 let result = mode
2061 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
2062 .unwrap();
2063 apply(&mut session, result);
2064 let result = mode
2065 .on_message(
2066 &session,
2067 &env(
2068 "coordinator",
2069 "ApprovalRequest",
2070 make_approval_request("r1", 3),
2071 ),
2072 )
2073 .unwrap();
2074 apply(&mut session, result);
2075 for participant in ["alice", "bob", "carol"] {
2076 let result = mode
2077 .on_message(
2078 &session,
2079 &env(participant, "Abstain", make_abstain("r1", "no opinion")),
2080 )
2081 .unwrap();
2082 apply(&mut session, result);
2083 }
2084 let commit = mode
2085 .on_message(
2086 &session,
2087 &env(
2088 "coordinator",
2089 "Commitment",
2090 commitment("quorum.rejected", false),
2091 ),
2092 )
2093 .unwrap();
2094 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2095 }
2096
2097 #[test]
2098 fn a_single_ballot_still_unlocks_the_unreachable_branch() {
2099 let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2103 let mut session = session_with(
2104 3,
2105 serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
2106 );
2107 let result = mode
2108 .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
2109 .unwrap();
2110 apply(&mut session, result);
2111 let result = mode
2112 .on_message(
2113 &session,
2114 &env(
2115 "coordinator",
2116 "ApprovalRequest",
2117 make_approval_request("r1", 3),
2118 ),
2119 )
2120 .unwrap();
2121 apply(&mut session, result);
2122 let result = mode
2123 .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
2124 .unwrap();
2125 apply(&mut session, result);
2126 let commit = mode
2127 .on_message(
2128 &session,
2129 &env(
2130 "coordinator",
2131 "Commitment",
2132 commitment("quorum.rejected", false),
2133 ),
2134 )
2135 .unwrap();
2136 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2137 }
2138}