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