Skip to main content

macp_modes/mode/
quorum.rs

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/// The approval bar an accepted `ApprovalRequest` must clear, as the mode
46/// resolves it against the session's bound policy.
47///
48/// Returned by [`QuorumMode::effective_threshold`] and, wrapped, by
49/// [`QuorumMode::effective_threshold_for_session`]. Both variants are
50/// reachable; "this session has no `ApprovalRequest` yet" is *not* a variant
51/// here — that question is answered by the `Option` the session-level
52/// accessor returns, so the two cannot be confused.
53///
54/// The policy's own inert case (`threshold.value <= 0`, which includes the
55/// schema default and therefore every session with no `threshold` rule at
56/// all) is **not** a variant either: the mode's documented fallback for it is
57/// the `ApprovalRequest`'s own `required_approvals`, so it arrives here
58/// already resolved as `Approvals(required_approvals)`. Surfacing
59/// [`macp_core::policy::rules::EffectiveThreshold::Inert`] instead would hand
60/// the fallback rule back to the caller, which is the re-implementation this
61/// accessor exists to delete (issue #146).
62///
63/// **Deliberately not `#[non_exhaustive]`**, matching
64/// [`macp_core::policy::rules::EffectiveThreshold`] and for the same reason:
65/// a `_` arm in a caller would silently reinterpret a future variant as one
66/// of these, and silently mis-handling a governance bar is the defect class
67/// issue #145 was. Adding a variant is a major `cargo-semver-checks` lint
68/// (`enum_variant_added`) and `release-plz.toml` sets `semver_check = true`,
69/// so it blocks the release PR rather than slipping out.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum ApprovalThreshold {
72    /// This many `Approve` ballots seal a **positive** commitment.
73    ///
74    /// Never zero, and for a session whose `ApprovalRequest` the mode
75    /// accepted, never above the declared participant count: `on_message`
76    /// constrains both the payload field and any policy replacement for it to
77    /// `1..=participants`.
78    Approvals(u32),
79    /// The bound policy admits no positive commitment at any approval count.
80    ///
81    /// Reached by `threshold.type: "weighted"` (not implemented here), an
82    /// unrecognised type, or a `percentage` over an empty participant set —
83    /// see [`macp_core::policy::rules::EffectiveThreshold::Unsatisfiable`].
84    /// `RegisterPolicy` refuses the first two, so a session can only carry
85    /// such a policy if the `PolicyDefinition` was constructed directly (or
86    /// restored from a checkpoint that predates those checks). The third is
87    /// not catchable at registration — there is no participant count there —
88    /// and is blocked by `QuorumMode::on_session_start` rejecting an empty
89    /// participant set. The mode seals **neither** outcome on such a session,
90    /// positive or negative.
91    Unsatisfiable,
92}
93
94pub struct QuorumMode {
95    evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
96}
97
98impl QuorumMode {
99    /// Construct the mode with an injected governance policy evaluator.
100    pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
101        Self { evaluator }
102    }
103
104    fn encode_state(state: &QuorumState) -> Vec<u8> {
105        crate::mode::util::encode_mode_state(state)
106    }
107
108    fn decode_state(data: &[u8]) -> Result<QuorumState, MacpError> {
109        crate::mode::util::decode_mode_state(data)
110    }
111
112    /// Resolve the effective approval threshold for one `ApprovalRequest`,
113    /// applying any policy override bound to the session.
114    ///
115    /// RFC-MACP-0011 §6: "When policy specifies a threshold override, it
116    /// replaces (not supplements) the required_approvals value from
117    /// ApprovalRequest."
118    ///
119    /// This is the bar the runtime itself enforces — the same call the
120    /// mode's own `commitment_ready` makes — so a caller that needs the number
121    /// should read it from here rather than re-deriving it (issue #146). The
122    /// arithmetic lives one layer down in
123    /// [`QuorumThreshold::effective`](macp_core::policy::rules::QuorumThreshold::effective),
124    /// the *same* function `evaluate_quorum_commitment_outcome` calls, so one
125    /// policy cannot produce two different bars in the two layers (issue
126    /// #145). This function adds only the mode's fallback for an inert rule:
127    /// `request.required_approvals`.
128    ///
129    /// Prefer [`Self::effective_threshold_for_session`] when you hold a
130    /// [`Session`] rather than a decoded [`ApprovalRequestRecord`]; it is the
131    /// same rule with the state decoding done for you. **Read the
132    /// non-monotonicity warning there before probing readiness by
133    /// experiment.**
134    ///
135    /// A rules object that fails to parse falls back to the schema defaults
136    /// (`unwrap_or_default`) and therefore to `required_approvals`, where the
137    /// evaluator instead denies the commitment. That divergence is recorded in
138    /// `ASSUMPTIONS.md` and deliberately left alone here.
139    pub fn effective_threshold(
140        session: &Session,
141        request: &ApprovalRequestRecord,
142    ) -> ApprovalThreshold {
143        let Some(ref policy) = session.policy_definition else {
144            return ApprovalThreshold::Approvals(request.required_approvals);
145        };
146        let rules: macp_core::policy::rules::QuorumPolicyRules =
147            serde_json::from_value(policy.rules.clone()).unwrap_or_default();
148        match rules.threshold.effective(session.participants.len()) {
149            macp_core::policy::rules::EffectiveThreshold::Inert => {
150                ApprovalThreshold::Approvals(request.required_approvals)
151            }
152            macp_core::policy::rules::EffectiveThreshold::Approvals(required) => {
153                ApprovalThreshold::Approvals(required)
154            }
155            macp_core::policy::rules::EffectiveThreshold::Unsatisfiable => {
156                ApprovalThreshold::Unsatisfiable
157            }
158        }
159    }
160
161    /// Resolve the effective approval threshold for a quorum **session**,
162    /// reading the accepted `ApprovalRequest` out of `session.mode_state`.
163    ///
164    /// This is the public entry point for "how many approvals does this
165    /// session need?" (issue #146). Each layer of the return type answers one
166    /// question, and the three answers must not be conflated:
167    ///
168    /// | Return | Meaning |
169    /// |--------|---------|
170    /// | `Ok(Some(`[`ApprovalThreshold::Approvals`]`(n)))` | `n` approvals seal a positive commitment |
171    /// | `Ok(Some(`[`ApprovalThreshold::Unsatisfiable`]`))` | the bound policy can never be satisfied; no outcome will seal |
172    /// | `Ok(None)` | no `ApprovalRequest` has been accepted yet — there is nothing to resolve |
173    /// | `Err(`[`MacpError::InvalidModeState`]`)` | `session.mode_state` is not decodable quorum state, so no answer would be honest |
174    ///
175    /// The `Err` arm also covers a session belonging to a different mode:
176    /// `QuorumState`'s fields are not `#[serde(default)]`, so another mode's
177    /// state (or a bare `{}`) fails to decode rather than reporting a
178    /// confident "no request".
179    ///
180    /// A session with no `threshold` policy rule — the common case — yields
181    /// `Ok(Some(Approvals(required_approvals)))`, the value from the
182    /// `ApprovalRequest` payload.
183    ///
184    /// # Do not probe commitment readiness to find this number
185    ///
186    /// The mode's internal `commitment_ready` predicate is **non-monotonic**
187    /// in the approval count. It fires when the bar is met *or* when it has become
188    /// mathematically unreachable, which is RFC-MACP-0011 §4a's trigger for a
189    /// *negative* commitment:
190    ///
191    /// ```text
192    /// approvals >= required || (counted > 0 && approvals + remaining < required)
193    /// //                                       ^ remaining = participants - counted
194    /// ```
195    ///
196    /// So readiness is a function of the whole ballot box — how many ballots
197    /// are in and how they split — not of the approval count alone, and it is
198    /// not a step function of that count. On three participants with
199    /// `required = 3`: three rejections (0 approvals) are ready, one approval
200    /// plus two rejections is ready, two approvals and one participant yet to
201    /// vote is **not** ready, three approvals are ready. A binary search over
202    /// readiness therefore returns a confident wrong answer, and even a
203    /// linear sweep measures the decline trigger rather than the bar. Call
204    /// this function instead; it returns the bar itself.
205    pub fn effective_threshold_for_session(
206        session: &Session,
207    ) -> Result<Option<ApprovalThreshold>, MacpError> {
208        if session.mode_state.is_empty() {
209            return Ok(None);
210        }
211        let state = Self::decode_state(&session.mode_state)?;
212        Ok(state
213            .request
214            .as_ref()
215            .map(|request| Self::effective_threshold(session, request)))
216    }
217
218    fn commitment_ready(session: &Session, state: &QuorumState) -> bool {
219        let request = match &state.request {
220            Some(request) => request,
221            None => return false,
222        };
223        let ApprovalThreshold::Approvals(required) = Self::effective_threshold(session, request)
224        else {
225            // Unsatisfiable threshold: neither outcome may be sealed. Without
226            // this, the unreachable-threshold branch below would fire on it
227            // and turn an unimplementable policy into a binding decline.
228            return false;
229        };
230        let approvals = state
231            .ballots
232            .values()
233            .filter(|ballot| ballot.choice == BallotChoice::Approve)
234            .count() as u32;
235        let total_eligible = session.participants.len() as u32;
236        let counted = state.ballots.len() as u32;
237        let remaining = total_eligible.saturating_sub(counted);
238        // Ready when the threshold is reached, or when it has become
239        // mathematically unreachable (RFC-MACP-0011 §4a, which makes that the
240        // trigger for a negative Commitment).
241        //
242        // `counted > 0` guards the second branch. With no ballot cast,
243        // `approvals + remaining < required` reduces to
244        // `participants.len() < required` — a state reachable only through a
245        // policy threshold larger than the participant pool, which
246        // `on_message`'s ApprovalRequest arm now refuses up front and which
247        // replay could otherwise re-introduce by binding edited policy rules
248        // to an older session. Left ungated, it let the coordinator seal a
249        // binding `quorum.rejected` before anyone voted (issue #145). Every
250        // §4b decline the RFC describes — all-abstain, or abstentions plus
251        // rejections — has at least one ballot behind it, so this refuses no
252        // legitimate decline.
253        approvals >= required || (counted > 0 && approvals + remaining < required)
254    }
255}
256
257impl Mode for QuorumMode {
258    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
259        match env.message_type.as_str() {
260            "ApprovalRequest" if env.sender == session.initiator_sender => Ok(()),
261            "ApprovalRequest" => Err(MacpError::Forbidden),
262            "Commitment" => check_commitment_authority(session, &env.sender),
263            _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
264            _ => Err(MacpError::Forbidden),
265        }
266    }
267
268    fn on_session_start(
269        &self,
270        session: &Session,
271        _env: &Envelope,
272    ) -> Result<ModeResponse, MacpError> {
273        if session.participants.is_empty() {
274            return Err(MacpError::InvalidPayload);
275        }
276        Ok(ModeResponse::PersistState(Self::encode_state(
277            &QuorumState::default(),
278        )))
279    }
280
281    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
282        let mut state = if session.mode_state.is_empty() {
283            QuorumState::default()
284        } else {
285            Self::decode_state(&session.mode_state)?
286        };
287
288        match env.message_type.as_str() {
289            "ApprovalRequest" => {
290                if env.sender != session.initiator_sender {
291                    return Err(MacpError::Forbidden);
292                }
293                let payload = ApprovalRequestPayload::decode(&*env.payload)
294                    .map_err(|_| MacpError::InvalidPayload)?;
295                if state.request.is_some()
296                    || payload.request_id.is_empty()
297                    || payload.required_approvals == 0
298                    || payload.required_approvals > session.participants.len() as u32
299                {
300                    return Err(MacpError::InvalidPayload);
301                }
302                let record = ApprovalRequestRecord {
303                    request_id: payload.request_id,
304                    action: payload.action,
305                    summary: payload.summary,
306                    details: payload.details,
307                    required_approvals: payload.required_approvals,
308                    requested_by: env.sender.clone(),
309                };
310                // RFC-MACP-0011 §6 says a policy `threshold` *replaces*
311                // `required_approvals`, so the replacement must satisfy the
312                // same domain the check above just enforced on the field it
313                // replaces: 1..=participants. A bar outside it makes the
314                // positive outcome impossible from the first message, and the
315                // coordinator could then seal a binding negative Commitment
316                // with no ballot cast (issue #145). Refusing here reports the
317                // misconfiguration at once instead of at commitment time, and
318                // keeps a dead session from collecting ballots that can never
319                // matter.
320                match Self::effective_threshold(session, &record) {
321                    ApprovalThreshold::Approvals(required)
322                        if required >= 1 && required <= session.participants.len() as u32 => {}
323                    other => {
324                        tracing::warn!(
325                            session_id = %session.session_id,
326                            policy_id = session
327                                .policy_definition
328                                .as_ref()
329                                .map(|p| p.policy_id.as_str())
330                                .unwrap_or(""),
331                            effective_threshold = ?other,
332                            participants = session.participants.len(),
333                            "quorum policy threshold is outside 1..=participants; \
334                             refusing the ApprovalRequest"
335                        );
336                        return Err(MacpError::InvalidPayload);
337                    }
338                }
339                state.request = Some(record);
340                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
341            }
342            "Approve" => {
343                let payload =
344                    ApprovePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
345                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
346                if payload.request_id != request.request_id
347                    || state.ballots.contains_key(&env.sender)
348                {
349                    return Err(MacpError::InvalidPayload);
350                }
351                state.ballots.insert(
352                    env.sender.clone(),
353                    BallotRecord {
354                        request_id: payload.request_id,
355                        choice: BallotChoice::Approve,
356                        sender: env.sender.clone(),
357                        reason: payload.reason,
358                    },
359                );
360                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
361            }
362            "Reject" => {
363                let payload =
364                    RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
365                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
366                if payload.request_id != request.request_id
367                    || state.ballots.contains_key(&env.sender)
368                {
369                    return Err(MacpError::InvalidPayload);
370                }
371                state.ballots.insert(
372                    env.sender.clone(),
373                    BallotRecord {
374                        request_id: payload.request_id,
375                        choice: BallotChoice::Reject,
376                        sender: env.sender.clone(),
377                        reason: payload.reason,
378                    },
379                );
380                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
381            }
382            "Abstain" => {
383                let payload =
384                    AbstainPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
385                let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?;
386                if payload.request_id != request.request_id
387                    || state.ballots.contains_key(&env.sender)
388                {
389                    return Err(MacpError::InvalidPayload);
390                }
391                state.ballots.insert(
392                    env.sender.clone(),
393                    BallotRecord {
394                        request_id: payload.request_id,
395                        choice: BallotChoice::Abstain,
396                        sender: env.sender.clone(),
397                        reason: payload.reason,
398                    },
399                );
400                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
401            }
402            "Commitment" => {
403                let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
404                if !Self::commitment_ready(session, &state) {
405                    return Err(MacpError::InvalidPayload);
406                }
407                // Governance policy gate (shared): fail closed, only
408                // an explicit Allow proceeds.
409                let approve_count = state
410                    .ballots
411                    .values()
412                    .filter(|b| b.choice == BallotChoice::Approve)
413                    .count();
414                let reject_count = state
415                    .ballots
416                    .values()
417                    .filter(|b| b.choice == BallotChoice::Reject)
418                    .count();
419                let abstain_count = state
420                    .ballots
421                    .values()
422                    .filter(|b| b.choice == BallotChoice::Abstain)
423                    .count();
424                enforce_commitment_policy(
425                    session,
426                    macp_core::policy::CommitmentMode::Quorum {
427                        approve_count,
428                        reject_count,
429                        abstain_count,
430                    },
431                    commitment.outcome_positive,
432                    &*self.evaluator,
433                )?;
434                Ok(ModeResponse::PersistAndResolve {
435                    state: Self::encode_state(&state),
436                    resolution: env.payload.clone(),
437                })
438            }
439            _ => Err(MacpError::InvalidPayload),
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use macp_core::session::Session;
448    use macp_pb::pb::CommitmentPayload;
449
450    fn base_session() -> Session {
451        Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
452            .ttl_ms(60_000)
453            .participants(vec!["alice".into(), "bob".into(), "carol".into()])
454            .mode_version("1.0.0")
455            .configuration_version("config")
456            .policy_version("policy")
457            .build()
458    }
459
460    fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
461        Envelope {
462            macp_version: "1.0".into(),
463            mode: "macp.mode.quorum.v1".into(),
464            message_type: message_type.into(),
465            message_id: format!("{}-{}", sender, message_type),
466            session_id: "s1".into(),
467            sender: sender.into(),
468            timestamp_unix_ms: 0,
469            payload,
470        }
471    }
472
473    fn commitment_payload() -> Vec<u8> {
474        commitment("quorum.approved", true)
475    }
476
477    fn commitment(action: &str, outcome_positive: bool) -> Vec<u8> {
478        CommitmentPayload {
479            commitment_id: "c1".into(),
480            action: action.into(),
481            authority_scope: "deploy".into(),
482            reason: "threshold met".into(),
483            mode_version: "1.0.0".into(),
484            policy_version: "policy".into(),
485            configuration_version: "config".into(),
486            outcome_positive,
487            supersedes: None,
488        }
489        .encode_to_vec()
490    }
491
492    fn apply(session: &mut Session, result: ModeResponse) {
493        match result {
494            ModeResponse::PersistState(data) => session.mode_state = data,
495            ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
496            _ => {}
497        }
498    }
499
500    fn make_approval_request(request_id: &str, required: u32) -> Vec<u8> {
501        ApprovalRequestPayload {
502            request_id: request_id.into(),
503            action: "deploy.production".into(),
504            summary: "Deploy v2".into(),
505            details: vec![],
506            required_approvals: required,
507        }
508        .encode_to_vec()
509    }
510
511    fn make_approve(request_id: &str, reason: &str) -> Vec<u8> {
512        ApprovePayload {
513            request_id: request_id.into(),
514            reason: reason.into(),
515        }
516        .encode_to_vec()
517    }
518
519    fn make_reject(request_id: &str, reason: &str) -> Vec<u8> {
520        RejectPayload {
521            request_id: request_id.into(),
522            reason: reason.into(),
523        }
524        .encode_to_vec()
525    }
526
527    fn make_abstain(request_id: &str, reason: &str) -> Vec<u8> {
528        AbstainPayload {
529            request_id: request_id.into(),
530            reason: reason.into(),
531        }
532        .encode_to_vec()
533    }
534
535    // --- Session Start ---
536
537    #[test]
538    fn session_start_initializes_state() {
539        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
540        let session = base_session();
541        let result = mode
542            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
543            .unwrap();
544        match result {
545            ModeResponse::PersistState(data) => {
546                let state: QuorumState = serde_json::from_slice(&data).unwrap();
547                assert!(state.request.is_none());
548                assert!(state.ballots.is_empty());
549            }
550            _ => panic!("Expected PersistState"),
551        }
552    }
553
554    #[test]
555    fn session_start_requires_participants() {
556        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
557        let mut session = base_session();
558        session.participants.clear();
559        let err = mode
560            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
561            .unwrap_err();
562        assert_eq!(err.to_string(), "InvalidPayload");
563    }
564
565    // --- ApprovalRequest ---
566
567    #[test]
568    fn approval_request_from_coordinator() {
569        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
570        let mut session = base_session();
571        let result = mode
572            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
573            .unwrap();
574        apply(&mut session, result);
575        let result = mode
576            .on_message(
577                &session,
578                &env(
579                    "coordinator",
580                    "ApprovalRequest",
581                    make_approval_request("r1", 2),
582                ),
583            )
584            .unwrap();
585        match result {
586            ModeResponse::PersistState(data) => {
587                let state: QuorumState = serde_json::from_slice(&data).unwrap();
588                assert!(state.request.is_some());
589                assert_eq!(state.request.unwrap().required_approvals, 2);
590            }
591            _ => panic!("Expected PersistState"),
592        }
593    }
594
595    #[test]
596    fn duplicate_approval_request_rejected() {
597        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
598        let mut session = base_session();
599        let result = mode
600            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
601            .unwrap();
602        apply(&mut session, result);
603        let result = mode
604            .on_message(
605                &session,
606                &env(
607                    "coordinator",
608                    "ApprovalRequest",
609                    make_approval_request("r1", 2),
610                ),
611            )
612            .unwrap();
613        apply(&mut session, result);
614        let err = mode
615            .on_message(
616                &session,
617                &env(
618                    "coordinator",
619                    "ApprovalRequest",
620                    make_approval_request("r2", 1),
621                ),
622            )
623            .unwrap_err();
624        assert_eq!(err.to_string(), "InvalidPayload");
625    }
626
627    #[test]
628    fn required_approvals_exceeds_participants_rejected() {
629        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
630        let mut session = base_session();
631        let result = mode
632            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
633            .unwrap();
634        apply(&mut session, result);
635        let err = mode
636            .on_message(
637                &session,
638                &env(
639                    "coordinator",
640                    "ApprovalRequest",
641                    make_approval_request("r1", 4),
642                ),
643            )
644            .unwrap_err();
645        assert_eq!(err.to_string(), "InvalidPayload");
646    }
647
648    #[test]
649    fn required_approvals_zero_rejected() {
650        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
651        let mut session = base_session();
652        let result = mode
653            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
654            .unwrap();
655        apply(&mut session, result);
656        let err = mode
657            .on_message(
658                &session,
659                &env(
660                    "coordinator",
661                    "ApprovalRequest",
662                    make_approval_request("r1", 0),
663                ),
664            )
665            .unwrap_err();
666        assert_eq!(err.to_string(), "InvalidPayload");
667    }
668
669    #[test]
670    fn non_coordinator_approval_request_rejected() {
671        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
672        let mut session = base_session();
673        let result = mode
674            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
675            .unwrap();
676        apply(&mut session, result);
677        let err = mode
678            .on_message(
679                &session,
680                &env("alice", "ApprovalRequest", make_approval_request("r1", 2)),
681            )
682            .unwrap_err();
683        assert_eq!(err.to_string(), "Forbidden");
684    }
685
686    // --- Ballots ---
687
688    #[test]
689    fn participant_can_approve() {
690        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
691        let mut session = base_session();
692        let result = mode
693            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
694            .unwrap();
695        apply(&mut session, result);
696        let result = mode
697            .on_message(
698                &session,
699                &env(
700                    "coordinator",
701                    "ApprovalRequest",
702                    make_approval_request("r1", 2),
703                ),
704            )
705            .unwrap();
706        apply(&mut session, result);
707        let result = mode
708            .on_message(
709                &session,
710                &env("alice", "Approve", make_approve("r1", "looks good")),
711            )
712            .unwrap();
713        match result {
714            ModeResponse::PersistState(data) => {
715                let state: QuorumState = serde_json::from_slice(&data).unwrap();
716                assert!(state.ballots.contains_key("alice"));
717                assert_eq!(state.ballots["alice"].choice, BallotChoice::Approve);
718            }
719            _ => panic!("Expected PersistState"),
720        }
721    }
722
723    #[test]
724    fn participant_can_reject() {
725        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
726        let mut session = base_session();
727        let result = mode
728            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
729            .unwrap();
730        apply(&mut session, result);
731        let result = mode
732            .on_message(
733                &session,
734                &env(
735                    "coordinator",
736                    "ApprovalRequest",
737                    make_approval_request("r1", 2),
738                ),
739            )
740            .unwrap();
741        apply(&mut session, result);
742        let result = mode
743            .on_message(
744                &session,
745                &env("alice", "Reject", make_reject("r1", "not ready")),
746            )
747            .unwrap();
748        match result {
749            ModeResponse::PersistState(data) => {
750                let state: QuorumState = serde_json::from_slice(&data).unwrap();
751                assert_eq!(state.ballots["alice"].choice, BallotChoice::Reject);
752            }
753            _ => panic!("Expected PersistState"),
754        }
755    }
756
757    #[test]
758    fn participant_can_abstain() {
759        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
760        let mut session = base_session();
761        let result = mode
762            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
763            .unwrap();
764        apply(&mut session, result);
765        let result = mode
766            .on_message(
767                &session,
768                &env(
769                    "coordinator",
770                    "ApprovalRequest",
771                    make_approval_request("r1", 2),
772                ),
773            )
774            .unwrap();
775        apply(&mut session, result);
776        let result = mode
777            .on_message(
778                &session,
779                &env("alice", "Abstain", make_abstain("r1", "no opinion")),
780            )
781            .unwrap();
782        match result {
783            ModeResponse::PersistState(data) => {
784                let state: QuorumState = serde_json::from_slice(&data).unwrap();
785                assert_eq!(state.ballots["alice"].choice, BallotChoice::Abstain);
786            }
787            _ => panic!("Expected PersistState"),
788        }
789    }
790
791    #[test]
792    fn duplicate_ballot_rejected() {
793        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
794        let mut session = base_session();
795        let result = mode
796            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
797            .unwrap();
798        apply(&mut session, result);
799        let result = mode
800            .on_message(
801                &session,
802                &env(
803                    "coordinator",
804                    "ApprovalRequest",
805                    make_approval_request("r1", 2),
806                ),
807            )
808            .unwrap();
809        apply(&mut session, result);
810        let result = mode
811            .on_message(
812                &session,
813                &env("alice", "Approve", make_approve("r1", "yes")),
814            )
815            .unwrap();
816        apply(&mut session, result);
817        let err = mode
818            .on_message(
819                &session,
820                &env("alice", "Approve", make_approve("r1", "again")),
821            )
822            .unwrap_err();
823        assert_eq!(err.to_string(), "InvalidPayload");
824    }
825
826    #[test]
827    fn ballot_before_request_rejected() {
828        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
829        let mut session = base_session();
830        let result = mode
831            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
832            .unwrap();
833        apply(&mut session, result);
834        let err = mode
835            .on_message(
836                &session,
837                &env("alice", "Approve", make_approve("r1", "premature")),
838            )
839            .unwrap_err();
840        assert_eq!(err.to_string(), "InvalidPayload");
841    }
842
843    #[test]
844    fn wrong_request_id_rejected() {
845        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
846        let mut session = base_session();
847        let result = mode
848            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
849            .unwrap();
850        apply(&mut session, result);
851        let result = mode
852            .on_message(
853                &session,
854                &env(
855                    "coordinator",
856                    "ApprovalRequest",
857                    make_approval_request("r1", 2),
858                ),
859            )
860            .unwrap();
861        apply(&mut session, result);
862        let err = mode
863            .on_message(
864                &session,
865                &env("alice", "Approve", make_approve("r2", "wrong id")),
866            )
867            .unwrap_err();
868        assert_eq!(err.to_string(), "InvalidPayload");
869    }
870
871    // --- Commitment ---
872
873    #[test]
874    fn commitment_when_threshold_reached() {
875        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
876        let mut session = base_session();
877        let result = mode
878            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
879            .unwrap();
880        apply(&mut session, result);
881        let result = mode
882            .on_message(
883                &session,
884                &env(
885                    "coordinator",
886                    "ApprovalRequest",
887                    make_approval_request("r1", 2),
888                ),
889            )
890            .unwrap();
891        apply(&mut session, result);
892        let result = mode
893            .on_message(
894                &session,
895                &env("alice", "Approve", make_approve("r1", "yes")),
896            )
897            .unwrap();
898        apply(&mut session, result);
899        let result = mode
900            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
901            .unwrap();
902        apply(&mut session, result);
903        let result = mode
904            .on_message(
905                &session,
906                &env("coordinator", "Commitment", commitment_payload()),
907            )
908            .unwrap();
909        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
910    }
911
912    #[test]
913    fn commitment_when_threshold_unreachable() {
914        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
915        let mut session = base_session();
916        let result = mode
917            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
918            .unwrap();
919        apply(&mut session, result);
920        let result = mode
921            .on_message(
922                &session,
923                &env(
924                    "coordinator",
925                    "ApprovalRequest",
926                    make_approval_request("r1", 3),
927                ),
928            )
929            .unwrap();
930        apply(&mut session, result);
931        // All 3 must approve for threshold=3, but alice rejects
932        let result = mode
933            .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
934            .unwrap();
935        apply(&mut session, result);
936        // Threshold is now unreachable (need 3, max possible = 2)
937        let result = mode
938            .on_message(
939                &session,
940                &env("coordinator", "Commitment", commitment_payload()),
941            )
942            .unwrap();
943        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
944    }
945
946    #[test]
947    fn commitment_before_threshold_rejected() {
948        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
949        let mut session = base_session();
950        let result = mode
951            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
952            .unwrap();
953        apply(&mut session, result);
954        let result = mode
955            .on_message(
956                &session,
957                &env(
958                    "coordinator",
959                    "ApprovalRequest",
960                    make_approval_request("r1", 2),
961                ),
962            )
963            .unwrap();
964        apply(&mut session, result);
965        let result = mode
966            .on_message(
967                &session,
968                &env("alice", "Approve", make_approve("r1", "yes")),
969            )
970            .unwrap();
971        apply(&mut session, result);
972        // Only 1 approval, threshold is 2, and 2 participants left can still vote
973        let err = mode
974            .on_message(
975                &session,
976                &env("coordinator", "Commitment", commitment_payload()),
977            )
978            .unwrap_err();
979        assert_eq!(err.to_string(), "InvalidPayload");
980    }
981
982    #[test]
983    fn non_coordinator_commitment_rejected() {
984        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
985        let mut session = base_session();
986        let result = mode
987            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
988            .unwrap();
989        apply(&mut session, result);
990        let result = mode
991            .on_message(
992                &session,
993                &env(
994                    "coordinator",
995                    "ApprovalRequest",
996                    make_approval_request("r1", 2),
997                ),
998            )
999            .unwrap();
1000        apply(&mut session, result);
1001        let result = mode
1002            .on_message(
1003                &session,
1004                &env("alice", "Approve", make_approve("r1", "yes")),
1005            )
1006            .unwrap();
1007        apply(&mut session, result);
1008        let result = mode
1009            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1010            .unwrap();
1011        apply(&mut session, result);
1012        let commit_env = env("alice", "Commitment", commitment_payload());
1013        let err = mode.authorize_sender(&session, &commit_env).unwrap_err();
1014        assert_eq!(err.to_string(), "Forbidden");
1015    }
1016
1017    // --- Full lifecycle ---
1018
1019    #[test]
1020    fn full_quorum_approve_lifecycle() {
1021        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1022        let mut session = base_session();
1023        let result = mode
1024            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1025            .unwrap();
1026        apply(&mut session, result);
1027        let result = mode
1028            .on_message(
1029                &session,
1030                &env(
1031                    "coordinator",
1032                    "ApprovalRequest",
1033                    make_approval_request("r1", 2),
1034                ),
1035            )
1036            .unwrap();
1037        apply(&mut session, result);
1038        let result = mode
1039            .on_message(
1040                &session,
1041                &env("alice", "Approve", make_approve("r1", "green")),
1042            )
1043            .unwrap();
1044        apply(&mut session, result);
1045        let result = mode
1046            .on_message(
1047                &session,
1048                &env("bob", "Approve", make_approve("r1", "ready")),
1049            )
1050            .unwrap();
1051        apply(&mut session, result);
1052        let result = mode
1053            .on_message(
1054                &session,
1055                &env("coordinator", "Commitment", commitment_payload()),
1056            )
1057            .unwrap();
1058        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1059    }
1060
1061    #[test]
1062    fn full_quorum_reject_lifecycle() {
1063        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1064        let mut session = base_session();
1065        let result = mode
1066            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1067            .unwrap();
1068        apply(&mut session, result);
1069        let result = mode
1070            .on_message(
1071                &session,
1072                &env(
1073                    "coordinator",
1074                    "ApprovalRequest",
1075                    make_approval_request("r1", 3),
1076                ),
1077            )
1078            .unwrap();
1079        apply(&mut session, result);
1080        let result = mode
1081            .on_message(
1082                &session,
1083                &env("alice", "Reject", make_reject("r1", "not ready")),
1084            )
1085            .unwrap();
1086        apply(&mut session, result);
1087        // Threshold unreachable: need 3, 1 rejected, only 2 left, max possible = 2
1088        let result = mode
1089            .on_message(
1090                &session,
1091                &env("coordinator", "Commitment", commitment_payload()),
1092            )
1093            .unwrap();
1094        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1095    }
1096
1097    // --- Commitment version mismatch ---
1098
1099    #[test]
1100    fn commitment_version_mismatch_rejected() {
1101        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1102        let mut session = base_session();
1103        let result = mode
1104            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1105            .unwrap();
1106        apply(&mut session, result);
1107        let result = mode
1108            .on_message(
1109                &session,
1110                &env(
1111                    "coordinator",
1112                    "ApprovalRequest",
1113                    make_approval_request("r1", 2),
1114                ),
1115            )
1116            .unwrap();
1117        apply(&mut session, result);
1118        let result = mode
1119            .on_message(
1120                &session,
1121                &env("alice", "Approve", make_approve("r1", "yes")),
1122            )
1123            .unwrap();
1124        apply(&mut session, result);
1125        let result = mode
1126            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1127            .unwrap();
1128        apply(&mut session, result);
1129        let bad_commitment = CommitmentPayload {
1130            commitment_id: "c1".into(),
1131            action: "quorum.approved".into(),
1132            authority_scope: "deploy".into(),
1133            reason: "threshold met".into(),
1134            mode_version: "wrong".into(),
1135            policy_version: "policy".into(),
1136            configuration_version: "config".into(),
1137            outcome_positive: true,
1138            supersedes: None,
1139        }
1140        .encode_to_vec();
1141        let err = mode
1142            .on_message(&session, &env("coordinator", "Commitment", bad_commitment))
1143            .unwrap_err();
1144        assert_eq!(err.to_string(), "InvalidPayload");
1145    }
1146
1147    // --- Unknown message type ---
1148
1149    #[test]
1150    fn unknown_message_type_rejected() {
1151        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1152        let mut session = base_session();
1153        let result = mode
1154            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1155            .unwrap();
1156        apply(&mut session, result);
1157        let err = mode
1158            .on_message(&session, &env("alice", "CustomType", vec![]))
1159            .unwrap_err();
1160        assert_eq!(err.to_string(), "InvalidPayload");
1161    }
1162
1163    // --- Policy ---
1164
1165    #[test]
1166    fn policy_denies_commitment_when_quorum_not_met_due_to_abstentions() {
1167        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1168        let mut session = base_session();
1169        // Require all 3 participants as voters, but abstentions don't count toward quorum
1170        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1171            policy_id: "test-strict-quorum".into(),
1172            mode: "macp.mode.quorum.v1".into(),
1173            description: "strict quorum".into(),
1174            rules: serde_json::json!({
1175                "threshold": { "type": "n_of_m", "value": 3 },
1176                "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" }
1177            }),
1178            schema_version: 1,
1179        });
1180        let result = mode
1181            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1182            .unwrap();
1183        apply(&mut session, result);
1184        let result = mode
1185            .on_message(
1186                &session,
1187                &env(
1188                    "coordinator",
1189                    "ApprovalRequest",
1190                    make_approval_request("r1", 2),
1191                ),
1192            )
1193            .unwrap();
1194        apply(&mut session, result);
1195        let result = mode
1196            .on_message(
1197                &session,
1198                &env("alice", "Approve", make_approve("r1", "yes")),
1199            )
1200            .unwrap();
1201        apply(&mut session, result);
1202        let result = mode
1203            .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes")))
1204            .unwrap();
1205        apply(&mut session, result);
1206        // carol abstains — with counts_toward_quorum=false, effective voters = 2 < 3
1207        let result = mode
1208            .on_message(
1209                &session,
1210                &env("carol", "Abstain", make_abstain("r1", "no opinion")),
1211            )
1212            .unwrap();
1213        apply(&mut session, result);
1214        // Commitment should be denied (quorum not met: 2 effective voters < 3 required)
1215        let err = mode
1216            .on_message(
1217                &session,
1218                &env("coordinator", "Commitment", commitment_payload()),
1219            )
1220            .unwrap_err();
1221        assert_eq!(err.to_string(), "PolicyDenied");
1222    }
1223
1224    // --- Negative outcome commitment ---
1225
1226    #[test]
1227    fn negative_outcome_quorum_rejected() {
1228        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1229        let mut session = base_session();
1230        // 3 participants, required_approvals = 2
1231        let result = mode
1232            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1233            .unwrap();
1234        apply(&mut session, result);
1235        let result = mode
1236            .on_message(
1237                &session,
1238                &env(
1239                    "coordinator",
1240                    "ApprovalRequest",
1241                    make_approval_request("r1", 2),
1242                ),
1243            )
1244            .unwrap();
1245        apply(&mut session, result);
1246        // Two participants reject, making the threshold (2 approvals) unreachable
1247        let result = mode
1248            .on_message(
1249                &session,
1250                &env("alice", "Reject", make_reject("r1", "not ready")),
1251            )
1252            .unwrap();
1253        apply(&mut session, result);
1254        let result = mode
1255            .on_message(
1256                &session,
1257                &env("bob", "Reject", make_reject("r1", "disagree")),
1258            )
1259            .unwrap();
1260        apply(&mut session, result);
1261        // Threshold is unreachable: 0 approvals + 1 remaining < 2 required
1262        // Commit with negative outcome
1263        let negative_commitment = commitment("quorum.rejected", false);
1264        let result = mode
1265            .on_message(
1266                &session,
1267                &env("coordinator", "Commitment", negative_commitment),
1268            )
1269            .unwrap();
1270        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1271    }
1272
1273    // --- All participants abstain — eligible for negative commitment ---
1274
1275    #[test]
1276    fn all_participants_abstain_allows_negative_commitment() {
1277        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1278        let mut session = base_session();
1279        // 3 participants, required_approvals = 2
1280        let result = mode
1281            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1282            .unwrap();
1283        apply(&mut session, result);
1284        let result = mode
1285            .on_message(
1286                &session,
1287                &env(
1288                    "coordinator",
1289                    "ApprovalRequest",
1290                    make_approval_request("r1", 2),
1291                ),
1292            )
1293            .unwrap();
1294        apply(&mut session, result);
1295        // All 3 participants abstain
1296        let result = mode
1297            .on_message(
1298                &session,
1299                &env("alice", "Abstain", make_abstain("r1", "neutral")),
1300            )
1301            .unwrap();
1302        apply(&mut session, result);
1303        let result = mode
1304            .on_message(
1305                &session,
1306                &env("bob", "Abstain", make_abstain("r1", "neutral")),
1307            )
1308            .unwrap();
1309        apply(&mut session, result);
1310        let result = mode
1311            .on_message(
1312                &session,
1313                &env("carol", "Abstain", make_abstain("r1", "neutral")),
1314            )
1315            .unwrap();
1316        apply(&mut session, result);
1317        // Threshold is unreachable: 0 approvals + 0 remaining < 2 required
1318        // commitment_ready() returns true, so a negative commitment should succeed
1319        let negative_commitment = CommitmentPayload {
1320            commitment_id: "c1".into(),
1321            action: "quorum.rejected".into(),
1322            authority_scope: "deploy".into(),
1323            reason: "all abstained".into(),
1324            mode_version: "1.0.0".into(),
1325            policy_version: "policy".into(),
1326            configuration_version: "config".into(),
1327            outcome_positive: false,
1328            supersedes: None,
1329        }
1330        .encode_to_vec();
1331        let result = mode
1332            .on_message(
1333                &session,
1334                &env("coordinator", "Commitment", negative_commitment),
1335            )
1336            .unwrap();
1337        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1338    }
1339
1340    // --- Initiator not in participants cannot cast ballot ---
1341
1342    #[test]
1343    fn initiator_not_in_participants_cannot_cast_ballot() {
1344        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1345        let mut session = base_session();
1346        // coordinator is initiator but NOT in participants
1347        // participants are alice, bob, carol (coordinator excluded)
1348        let result = mode
1349            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1350            .unwrap();
1351        apply(&mut session, result);
1352        let result = mode
1353            .on_message(
1354                &session,
1355                &env(
1356                    "coordinator",
1357                    "ApprovalRequest",
1358                    make_approval_request("r1", 2),
1359                ),
1360            )
1361            .unwrap();
1362        apply(&mut session, result);
1363        // coordinator tries to Approve — should be Forbidden because coordinator
1364        // is not a declared participant
1365        let approve_env = env("coordinator", "Approve", make_approve("r1", "yes"));
1366        let err = mode.authorize_sender(&session, &approve_env).unwrap_err();
1367        assert_eq!(err.to_string(), "Forbidden");
1368    }
1369
1370    // ── Quorum policy threshold override (RFC-MACP-0011) ───────────
1371
1372    #[test]
1373    fn policy_threshold_overrides_required_approvals() {
1374        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1375        let mut session = base_session();
1376        // Policy sets n_of_m threshold to 1, while ApprovalRequest requires 3
1377        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1378            policy_id: "threshold-override".into(),
1379            mode: "macp.mode.quorum.v1".into(),
1380            description: "low threshold".into(),
1381            rules: serde_json::json!({
1382                "threshold": { "type": "n_of_m", "value": 1.0 }
1383            }),
1384            schema_version: 1,
1385        });
1386        let result = mode
1387            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1388            .unwrap();
1389        apply(&mut session, result);
1390        let result = mode
1391            .on_message(
1392                &session,
1393                &env(
1394                    "coordinator",
1395                    "ApprovalRequest",
1396                    make_approval_request("r1", 3), // requires 3, but policy overrides to 1
1397                ),
1398            )
1399            .unwrap();
1400        apply(&mut session, result);
1401        // Just 1 approval should make commitment ready (policy overrides to 1)
1402        let result = mode
1403            .on_message(
1404                &session,
1405                &env("alice", "Approve", make_approve("r1", "yes")),
1406            )
1407            .unwrap();
1408        apply(&mut session, result);
1409        // Commitment should succeed
1410        let commit = mode
1411            .on_message(
1412                &session,
1413                &env("coordinator", "Commitment", commitment_payload()),
1414            )
1415            .unwrap();
1416        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1417    }
1418
1419    #[test]
1420    fn policy_percentage_threshold() {
1421        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1422        let mut session = base_session();
1423        // 3 participants, 50% → ceil(1.5) = 2 required
1424        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1425            policy_id: "pct-override".into(),
1426            mode: "macp.mode.quorum.v1".into(),
1427            description: "percentage threshold".into(),
1428            rules: serde_json::json!({
1429                "threshold": { "type": "percentage", "value": 50.0 }
1430            }),
1431            schema_version: 1,
1432        });
1433        let result = mode
1434            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1435            .unwrap();
1436        apply(&mut session, result);
1437        let result = mode
1438            .on_message(
1439                &session,
1440                &env(
1441                    "coordinator",
1442                    "ApprovalRequest",
1443                    make_approval_request("r1", 3),
1444                ),
1445            )
1446            .unwrap();
1447        apply(&mut session, result);
1448        // 1 approval is not enough (need 2 for 50% of 3)
1449        let result = mode
1450            .on_message(
1451                &session,
1452                &env("alice", "Approve", make_approve("r1", "yes")),
1453            )
1454            .unwrap();
1455        apply(&mut session, result);
1456        let err = mode
1457            .on_message(
1458                &session,
1459                &env("coordinator", "Commitment", commitment_payload()),
1460            )
1461            .unwrap_err();
1462        assert_eq!(err.to_string(), "InvalidPayload");
1463        // 2nd approval makes it ready
1464        let result = mode
1465            .on_message(
1466                &session,
1467                &env("bob", "Approve", make_approve("r1", "agreed")),
1468            )
1469            .unwrap();
1470        apply(&mut session, result);
1471        let commit = mode
1472            .on_message(
1473                &session,
1474                &env("coordinator", "Commitment", commitment_payload()),
1475            )
1476            .unwrap();
1477        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1478    }
1479
1480    #[test]
1481    fn all_abstain_eligible_for_negative_commitment() {
1482        // RFC-MACP-0011: "When all eligible participants have abstained, the Session
1483        // becomes eligible for Commitment with a negative outcome."
1484        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1485        let mut session = base_session();
1486        let result = mode
1487            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1488            .unwrap();
1489        apply(&mut session, result);
1490        let result = mode
1491            .on_message(
1492                &session,
1493                &env(
1494                    "coordinator",
1495                    "ApprovalRequest",
1496                    make_approval_request("r1", 2),
1497                ),
1498            )
1499            .unwrap();
1500        apply(&mut session, result);
1501        // All 3 participants abstain
1502        for sender in &["alice", "bob", "carol"] {
1503            let result = mode
1504                .on_message(
1505                    &session,
1506                    &env(sender, "Abstain", make_abstain("r1", "neutral")),
1507                )
1508                .unwrap();
1509            apply(&mut session, result);
1510        }
1511        // Commitment should be ready (0 approvals + 0 remaining < 2 required)
1512        let commit = mode
1513            .on_message(
1514                &session,
1515                &env(
1516                    "coordinator",
1517                    "Commitment",
1518                    commitment("quorum.rejected", false),
1519                ),
1520            )
1521            .unwrap();
1522        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1523    }
1524
1525    // ── Threshold rounding parity across mode and evaluator (#145) ──
1526
1527    fn quorum_policy(rules: serde_json::Value) -> macp_core::policy::PolicyDefinition {
1528        macp_core::policy::PolicyDefinition {
1529            policy_id: "threshold-parity".into(),
1530            mode: "macp.mode.quorum.v1".into(),
1531            description: "threshold parity fixture".into(),
1532            rules,
1533            schema_version: 1,
1534        }
1535    }
1536
1537    fn session_with(participants: usize, rules: serde_json::Value) -> Session {
1538        let names = ["alice", "bob", "carol", "dave", "erin"];
1539        let mut session = Session::builder("s1", "macp.mode.quorum.v1", "coordinator")
1540            .ttl_ms(60_000)
1541            .participants(
1542                names[..participants]
1543                    .iter()
1544                    .map(|s| s.to_string())
1545                    .collect(),
1546            )
1547            .mode_version("1.0.0")
1548            .configuration_version("config")
1549            .policy_version("policy")
1550            .build();
1551        session.policy_definition = Some(quorum_policy(rules));
1552        session
1553    }
1554
1555    /// A request whose own `required_approvals` is deliberately unlike any
1556    /// policy answer, so a silent fallback to it would show up as a mismatch
1557    /// rather than a pass.
1558    fn matrix_request() -> ApprovalRequestRecord {
1559        ApprovalRequestRecord {
1560            request_id: "r1".into(),
1561            action: "deploy.production".into(),
1562            summary: "Deploy v2".into(),
1563            details: vec![],
1564            required_approvals: 99,
1565            requested_by: "coordinator".into(),
1566        }
1567    }
1568
1569    /// Collapse the public [`ApprovalThreshold`] to the `Option<u32>` the
1570    /// evaluator probe below speaks, so the two are directly comparable.
1571    fn as_option(threshold: ApprovalThreshold) -> Option<u32> {
1572        match threshold {
1573            ApprovalThreshold::Approvals(required) => Some(required),
1574            ApprovalThreshold::Unsatisfiable => None,
1575        }
1576    }
1577
1578    /// The approval bar the **mode** derives from a request in hand.
1579    fn mode_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1580        let session = session_with(participants, rules);
1581        as_option(QuorumMode::effective_threshold(&session, &matrix_request()))
1582    }
1583
1584    /// The same bar read through the **session-level** accessor, which decodes
1585    /// the request out of `session.mode_state` itself (issue #146 — this is
1586    /// the form a downstream caller reaches). The state is seated directly
1587    /// rather than through `on_message` because the matrix deliberately
1588    /// includes over-participant thresholds, which the `ApprovalRequest` arm
1589    /// refuses; the accessor must still answer for a session that carries one
1590    /// (replay can rebind edited policy rules to an accepted request).
1591    fn session_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1592        let mut session = session_with(participants, rules);
1593        session.mode_state = QuorumMode::encode_state(&QuorumState {
1594            request: Some(matrix_request()),
1595            ballots: BTreeMap::new(),
1596        });
1597        as_option(
1598            QuorumMode::effective_threshold_for_session(&session)
1599                .expect("state seated by encode_state decodes")
1600                .expect("a request was seated"),
1601        )
1602    }
1603
1604    /// The approval bar the **evaluator** derives, recovered from its public
1605    /// behaviour: it denies a positive commitment while `approve_count` is
1606    /// below the bar and allows it at or above, so the smallest allowed count
1607    /// *is* the bar. `None` when no count is ever allowed.
1608    fn evaluator_required(participants: usize, rules: serde_json::Value) -> Option<u32> {
1609        let policy = quorum_policy(rules);
1610        (0u32..=64).find(|approve| {
1611            matches!(
1612                macp_policy::evaluator::evaluate_quorum_commitment_outcome(
1613                    &policy,
1614                    *approve as usize,
1615                    0,
1616                    0,
1617                    participants,
1618                    true,
1619                ),
1620                macp_core::policy::PolicyDecision::Allow { .. }
1621            )
1622        })
1623    }
1624
1625    #[test]
1626    fn fractional_threshold_ceils_to_one_in_both_layers() {
1627        // Issue #145: the mode truncated (`0.5 as u32 == 0`) while the
1628        // evaluator ceiled, and a bar of 0 is met before any ballot is cast.
1629        let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } });
1630        assert_eq!(mode_required(3, rules.clone()), Some(1));
1631        assert_eq!(evaluator_required(3, rules), Some(1));
1632    }
1633
1634    #[test]
1635    fn threshold_is_floored_at_one_so_a_zero_bar_is_unreachable() {
1636        // Every positive value below 1 resolves to 1, in both layers.
1637        for value in [0.000_001, 0.1, 0.49, 0.5, 0.99] {
1638            let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": value } });
1639            assert_eq!(mode_required(3, rules.clone()), Some(1), "value {value}");
1640            assert_eq!(evaluator_required(3, rules), Some(1), "value {value}");
1641        }
1642    }
1643
1644    #[test]
1645    fn zero_ballot_decline_is_refused_under_a_fractional_threshold() {
1646        // With the old truncation the bar was 0, so `approvals >= required`
1647        // held with an empty ballot box and this negative commitment sealed.
1648        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1649        let mut session = session_with(
1650            3,
1651            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0.5 } }),
1652        );
1653        let result = mode
1654            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1655            .unwrap();
1656        apply(&mut session, result);
1657        let result = mode
1658            .on_message(
1659                &session,
1660                &env(
1661                    "coordinator",
1662                    "ApprovalRequest",
1663                    make_approval_request("r1", 3),
1664                ),
1665            )
1666            .unwrap();
1667        apply(&mut session, result);
1668        let err = mode
1669            .on_message(
1670                &session,
1671                &env(
1672                    "coordinator",
1673                    "Commitment",
1674                    commitment("quorum.rejected", false),
1675                ),
1676            )
1677            .unwrap_err();
1678        assert_eq!(err.to_string(), "InvalidPayload");
1679        // Nor does the positive one seal with an empty ballot box.
1680        let err = mode
1681            .on_message(
1682                &session,
1683                &env("coordinator", "Commitment", commitment_payload()),
1684            )
1685            .unwrap_err();
1686        assert_eq!(err.to_string(), "InvalidPayload");
1687        // One approval meets the ceiled bar, and the session resolves.
1688        let result = mode
1689            .on_message(
1690                &session,
1691                &env("alice", "Approve", make_approve("r1", "yes")),
1692            )
1693            .unwrap();
1694        apply(&mut session, result);
1695        let commit = mode
1696            .on_message(
1697                &session,
1698                &env("coordinator", "Commitment", commitment_payload()),
1699            )
1700            .unwrap();
1701        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1702    }
1703
1704    #[test]
1705    fn weighted_threshold_is_unsatisfiable_not_a_raw_approval_count() {
1706        // `weighted` used to fall through the `_` arm in both layers and be
1707        // read as a raw count. `threshold.value` is typed `integer` by the
1708        // canonical schema and per-participant quorum weights are not
1709        // modelled, so there is nothing to implement — it fails closed.
1710        let rules = serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } });
1711        assert_eq!(mode_required(3, rules.clone()), None);
1712        assert_eq!(evaluator_required(3, rules.clone()), None);
1713
1714        // An unrecognised type fails closed the same way.
1715        let unknown = serde_json::json!({ "threshold": { "type": "two_thirds", "value": 2 } });
1716        assert_eq!(mode_required(3, unknown.clone()), None);
1717        assert_eq!(evaluator_required(3, unknown), None);
1718
1719        // End to end: the ApprovalRequest is refused, so no ballot is ever
1720        // cast into a session that could not resolve.
1721        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1722        let mut session = session_with(3, rules);
1723        let result = mode
1724            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1725            .unwrap();
1726        apply(&mut session, result);
1727        let err = mode
1728            .on_message(
1729                &session,
1730                &env(
1731                    "coordinator",
1732                    "ApprovalRequest",
1733                    make_approval_request("r1", 2),
1734                ),
1735            )
1736            .unwrap_err();
1737        assert_eq!(err.to_string(), "InvalidPayload");
1738    }
1739
1740    #[test]
1741    fn mode_and_evaluator_agree_across_the_threshold_matrix() {
1742        // RFC-MACP-0011 §7: given the same participants, threshold and
1743        // ballots, every implementation must derive the same commitment
1744        // eligibility. Both layers now resolve through
1745        // `QuorumThreshold::effective`; this asserts the property they are
1746        // supposed to have rather than the fact that they share a function.
1747        //
1748        // `value = 0` is deliberately OUT of the matrix: both layers gate on
1749        // `value > 0.0` and diverge *outside* that gate (the mode falls back
1750        // to `required_approvals`, the evaluator applies no bar at all). That
1751        // residual divergence is recorded in `ASSUMPTIONS.md` and is not what
1752        // the ceil+floor fix addresses.
1753        let cases: [(&str, [f64; 5]); 3] = [
1754            ("n_of_m", [0.5, 1.0, 2.0, 3.0, 5.0]),
1755            ("count", [0.5, 1.0, 2.0, 3.0, 5.0]),
1756            // Fractional, sub-participant, exact, whole, and over-100.
1757            ("percentage", [0.5, 33.0, 50.0, 100.0, 150.0]),
1758        ];
1759        for (threshold_type, values) in cases {
1760            for value in values {
1761                for participants in 1..=3usize {
1762                    let rules = serde_json::json!({
1763                        "threshold": { "type": threshold_type, "value": value }
1764                    });
1765                    let mode = mode_required(participants, rules.clone());
1766                    let session = session_required(participants, rules.clone());
1767                    let evaluator = evaluator_required(participants, rules);
1768                    assert_eq!(
1769                        mode, evaluator,
1770                        "type={threshold_type} value={value} participants={participants}"
1771                    );
1772                    // Issue #146: the public session-level accessor must
1773                    // report the same bar the evaluator enforces, not merely
1774                    // the same bar the request-level form derives.
1775                    assert_eq!(
1776                        session, evaluator,
1777                        "session-level accessor: type={threshold_type} value={value} \
1778                         participants={participants}"
1779                    );
1780                    assert_ne!(
1781                        mode,
1782                        Some(0),
1783                        "type={threshold_type} value={value} participants={participants}: \
1784                         a bar of 0 is met before any ballot is cast"
1785                    );
1786                }
1787            }
1788        }
1789    }
1790
1791    #[test]
1792    fn session_threshold_separates_no_request_from_unsatisfiable() {
1793        // Issue #146: the accessor's three answers must stay distinguishable.
1794        // Before this phase the inner function returned `Option<u32>` with
1795        // `None` meaning "unsatisfiable"; a session-level `Option<u32>` would
1796        // have made "no request yet" and "this policy can never be met" the
1797        // same value, which is the one thing a caller cannot afford here.
1798        let rules = serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } });
1799
1800        // Nothing accepted yet: empty mode_state, and explicitly seated
1801        // default state (replay writes the latter at SessionStart).
1802        let mut session = session_with(3, rules.clone());
1803        assert_eq!(
1804            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1805            None
1806        );
1807        session.mode_state = QuorumMode::encode_state(&QuorumState::default());
1808        assert_eq!(
1809            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1810            None
1811        );
1812
1813        // Request accepted under a satisfiable policy: the bar itself.
1814        session.mode_state = QuorumMode::encode_state(&QuorumState {
1815            request: Some(matrix_request()),
1816            ballots: BTreeMap::new(),
1817        });
1818        assert_eq!(
1819            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1820            Some(ApprovalThreshold::Approvals(2))
1821        );
1822
1823        // Same seated request under an unsatisfiable policy: a distinct
1824        // answer, not the `None` that means "no request".
1825        session.policy_definition = Some(quorum_policy(
1826            serde_json::json!({ "threshold": { "type": "weighted", "value": 2 } }),
1827        ));
1828        assert_eq!(
1829            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1830            Some(ApprovalThreshold::Unsatisfiable)
1831        );
1832
1833        // Undecodable state is a third answer again: no honest bar exists, so
1834        // it is not silently reported as "no request".
1835        session.mode_state = b"{not-quorum-state".to_vec();
1836        assert_eq!(
1837            QuorumMode::effective_threshold_for_session(&session)
1838                .unwrap_err()
1839                .to_string(),
1840            "InvalidModeState"
1841        );
1842    }
1843
1844    #[test]
1845    fn session_threshold_falls_back_to_the_requests_own_required_approvals() {
1846        // The common case a caller most needs: no policy override at all
1847        // resolves to the ApprovalRequest payload's own value, already
1848        // applied, so nothing about the fallback rule is left for the caller
1849        // to re-implement (issue #146). Driven through `on_message` so the
1850        // state is the one the runtime itself writes.
1851        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1852        let mut session = base_session();
1853        session.policy_definition = None;
1854        let result = mode
1855            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1856            .unwrap();
1857        apply(&mut session, result);
1858        assert_eq!(
1859            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1860            None
1861        );
1862        let result = mode
1863            .on_message(
1864                &session,
1865                &env(
1866                    "coordinator",
1867                    "ApprovalRequest",
1868                    make_approval_request("r1", 2),
1869                ),
1870            )
1871            .unwrap();
1872        apply(&mut session, result);
1873        assert_eq!(
1874            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1875            Some(ApprovalThreshold::Approvals(2))
1876        );
1877        // An inert policy rule (`value: 0`, the schema default) is the same
1878        // fallback, not a separate outcome the caller has to handle.
1879        session.policy_definition = Some(quorum_policy(
1880            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 0 } }),
1881        ));
1882        assert_eq!(
1883            QuorumMode::effective_threshold_for_session(&session).unwrap(),
1884            Some(ApprovalThreshold::Approvals(2))
1885        );
1886    }
1887
1888    #[test]
1889    fn over_participant_policy_threshold_refuses_the_approval_request() {
1890        // RFC-MACP-0011 §6: a policy threshold *replaces* `required_approvals`,
1891        // which this arm already constrains to 1..=participants. A replacement
1892        // outside that domain makes the positive outcome impossible from the
1893        // first message, and `commitment_ready`'s unreachable branch would let
1894        // the coordinator seal a binding `quorum.rejected` with no ballot cast.
1895        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1896        let mut session = session_with(
1897            3,
1898            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
1899        );
1900        let result = mode
1901            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1902            .unwrap();
1903        apply(&mut session, result);
1904        let err = mode
1905            .on_message(
1906                &session,
1907                &env(
1908                    "coordinator",
1909                    "ApprovalRequest",
1910                    make_approval_request("r1", 3),
1911                ),
1912            )
1913            .unwrap_err();
1914        assert_eq!(err.to_string(), "InvalidPayload");
1915        // A threshold exactly at the participant count is still fine.
1916        session.policy_definition = Some(quorum_policy(
1917            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
1918        ));
1919        assert!(mode
1920            .on_message(
1921                &session,
1922                &env(
1923                    "coordinator",
1924                    "ApprovalRequest",
1925                    make_approval_request("r1", 3),
1926                ),
1927            )
1928            .is_ok());
1929    }
1930
1931    #[test]
1932    fn zero_ballot_decline_is_refused_when_policy_rebinds_over_the_participant_pool() {
1933        // The belt behind the ApprovalRequest guard: replay re-resolves
1934        // `policy_version` against the registry, so a policy edited between
1935        // runs can bind a larger threshold to a session whose request was
1936        // already accepted. The unreachable-threshold branch must not fire
1937        // with an empty ballot box.
1938        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1939        let mut session = session_with(
1940            3,
1941            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
1942        );
1943        let result = mode
1944            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1945            .unwrap();
1946        apply(&mut session, result);
1947        let result = mode
1948            .on_message(
1949                &session,
1950                &env(
1951                    "coordinator",
1952                    "ApprovalRequest",
1953                    make_approval_request("r1", 3),
1954                ),
1955            )
1956            .unwrap();
1957        apply(&mut session, result);
1958        // The policy is edited out from under the accepted request.
1959        session.policy_definition = Some(quorum_policy(
1960            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 5 } }),
1961        ));
1962        let err = mode
1963            .on_message(
1964                &session,
1965                &env(
1966                    "coordinator",
1967                    "Commitment",
1968                    commitment("quorum.rejected", false),
1969                ),
1970            )
1971            .unwrap_err();
1972        assert_eq!(err.to_string(), "InvalidPayload");
1973    }
1974
1975    #[test]
1976    fn genuine_unreachable_threshold_still_permits_a_negative_commitment() {
1977        // RFC-MACP-0011 §4a/§4b: once ballots make the bar unreachable, the
1978        // negative Commitment is the legitimate terminal. The zero-ballot gate
1979        // must not cost us this — all three participants abstain, which is
1980        // §4b's own worked example.
1981        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1982        let mut session = session_with(
1983            3,
1984            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 2 } }),
1985        );
1986        let result = mode
1987            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
1988            .unwrap();
1989        apply(&mut session, result);
1990        let result = mode
1991            .on_message(
1992                &session,
1993                &env(
1994                    "coordinator",
1995                    "ApprovalRequest",
1996                    make_approval_request("r1", 3),
1997                ),
1998            )
1999            .unwrap();
2000        apply(&mut session, result);
2001        for participant in ["alice", "bob", "carol"] {
2002            let result = mode
2003                .on_message(
2004                    &session,
2005                    &env(participant, "Abstain", make_abstain("r1", "no opinion")),
2006                )
2007                .unwrap();
2008            apply(&mut session, result);
2009        }
2010        let commit = mode
2011            .on_message(
2012                &session,
2013                &env(
2014                    "coordinator",
2015                    "Commitment",
2016                    commitment("quorum.rejected", false),
2017                ),
2018            )
2019            .unwrap();
2020        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2021    }
2022
2023    #[test]
2024    fn a_single_ballot_still_unlocks_the_unreachable_branch() {
2025        // The gate is "at least one ballot", not "all ballots": one rejection
2026        // against a 3-of-3 bar makes the threshold unreachable and the decline
2027        // legitimate per §4a.
2028        let mode = QuorumMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2029        let mut session = session_with(
2030            3,
2031            serde_json::json!({ "threshold": { "type": "n_of_m", "value": 3 } }),
2032        );
2033        let result = mode
2034            .on_session_start(&session, &env("coordinator", "SessionStart", vec![]))
2035            .unwrap();
2036        apply(&mut session, result);
2037        let result = mode
2038            .on_message(
2039                &session,
2040                &env(
2041                    "coordinator",
2042                    "ApprovalRequest",
2043                    make_approval_request("r1", 3),
2044                ),
2045            )
2046            .unwrap();
2047        apply(&mut session, result);
2048        let result = mode
2049            .on_message(&session, &env("alice", "Reject", make_reject("r1", "no")))
2050            .unwrap();
2051        apply(&mut session, result);
2052        let commit = mode
2053            .on_message(
2054                &session,
2055                &env(
2056                    "coordinator",
2057                    "Commitment",
2058                    commitment("quorum.rejected", false),
2059                ),
2060            )
2061            .unwrap();
2062        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2063    }
2064}