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