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