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