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