Skip to main content

macp_core/policy/
rules.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4// ── Decision Policy Rules ───────────────────────────────────────────
5
6#[derive(Clone, Debug, Serialize, Deserialize, Default)]
7pub struct DecisionPolicyRules {
8    #[serde(default)]
9    pub voting: VotingRules,
10    #[serde(default)]
11    pub objection_handling: ObjectionHandlingRules,
12    #[serde(default)]
13    pub evaluation: EvaluationRules,
14    #[serde(default)]
15    pub commitment: CommitmentRules,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct VotingRules {
20    #[serde(default = "default_algorithm")]
21    pub algorithm: String,
22    #[serde(default = "default_threshold")]
23    pub threshold: f64,
24    #[serde(default)]
25    pub quorum: QuorumRules,
26    #[serde(default)]
27    pub weights: HashMap<String, f64>,
28}
29
30impl Default for VotingRules {
31    fn default() -> Self {
32        Self {
33            algorithm: default_algorithm(),
34            threshold: default_threshold(),
35            quorum: QuorumRules::default(),
36            weights: HashMap::new(),
37        }
38    }
39}
40
41fn default_algorithm() -> String {
42    "none".into()
43}
44
45fn default_threshold() -> f64 {
46    0.5
47}
48
49/// Quorum rules used inside Decision mode's `voting.quorum`.
50#[derive(Clone, Debug, Serialize, Deserialize)]
51pub struct QuorumRules {
52    #[serde(default = "default_quorum_type", rename = "type")]
53    pub quorum_type: String,
54    #[serde(default)]
55    pub value: f64,
56}
57
58impl Default for QuorumRules {
59    fn default() -> Self {
60        Self {
61            quorum_type: default_quorum_type(),
62            value: 0.0,
63        }
64    }
65}
66
67fn default_quorum_type() -> String {
68    "count".into()
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ObjectionHandlingRules {
73    /// RFC-MACP-0012: objections with severity "critical" trigger veto logic.
74    #[serde(default, alias = "critical_severity_vetoes")]
75    pub critical_severity_vetoes: bool,
76    #[serde(default = "default_veto_threshold")]
77    pub veto_threshold: u32,
78    /// What a triggered critical-objection veto does to a commitment.
79    /// Defaults to [`CriticalObjectionAction::Deny`] — the historical hard-stop
80    /// that blocks every commitment. Operators in adverse-action domains
81    /// (claims/lending) generally want `deny` or `hold`; `finalize_decline` is
82    /// opt-in because auto-finalizing a denial off a single critical objection
83    /// is itself a regulated adverse action.
84    #[serde(default)]
85    pub critical_objection_action: CriticalObjectionAction,
86}
87
88impl Default for ObjectionHandlingRules {
89    fn default() -> Self {
90        Self {
91            critical_severity_vetoes: false,
92            veto_threshold: default_veto_threshold(),
93            critical_objection_action: CriticalObjectionAction::default(),
94        }
95    }
96}
97
98fn default_veto_threshold() -> u32 {
99    1
100}
101
102/// How a triggered critical-objection veto resolves a commitment attempt.
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
104#[serde(rename_all = "snake_case")]
105pub enum CriticalObjectionAction {
106    /// Hard-stop: the veto blocks every commitment, positive or negative
107    /// (historical behavior, conservative default).
108    #[default]
109    Deny,
110    /// The veto permits a *negative* commitment (`outcome_positive = false`) to
111    /// finalize, while still blocking a positive one.
112    FinalizeDecline,
113    /// The veto blocks the commitment but signals the session should be held
114    /// open for human escalation rather than treated as a permanent denial.
115    /// At the evaluator layer this denies the commitment (leaving the session
116    /// open); the distinct reason string marks it as an escalation hold.
117    Hold,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize)]
121pub struct EvaluationRules {
122    #[serde(default)]
123    pub required_before_voting: bool,
124    #[serde(default)]
125    pub minimum_confidence: f64,
126}
127
128impl Default for EvaluationRules {
129    fn default() -> Self {
130        Self {
131            required_before_voting: false,
132            minimum_confidence: 0.0,
133        }
134    }
135}
136
137// `CommitmentRules` is shared with the modes (which read it directly to
138// authorize commitments), so it lives in `macp-core`. Re-exported here so the
139// per-mode rule structs below and `crate::rules::CommitmentRules` keep
140// resolving.
141pub use super::CommitmentRules;
142
143// ── Proposal Policy Rules (RFC-MACP-0012 Section 4.3) ──────────────
144
145#[derive(Clone, Debug, Serialize, Deserialize, Default)]
146pub struct ProposalPolicyRules {
147    #[serde(default)]
148    pub acceptance: ProposalAcceptanceRules,
149    #[serde(default)]
150    pub counter_proposal: CounterProposalRules,
151    #[serde(default)]
152    pub rejection: RejectionRules,
153    #[serde(default)]
154    pub commitment: CommitmentRules,
155}
156
157#[derive(Clone, Debug, Serialize, Deserialize)]
158pub struct ProposalAcceptanceRules {
159    #[serde(default = "default_acceptance_criterion")]
160    pub criterion: String,
161}
162
163impl Default for ProposalAcceptanceRules {
164    fn default() -> Self {
165        Self {
166            criterion: default_acceptance_criterion(),
167        }
168    }
169}
170
171fn default_acceptance_criterion() -> String {
172    "all_parties".into()
173}
174
175#[derive(Clone, Debug, Default, Serialize, Deserialize)]
176pub struct CounterProposalRules {
177    #[serde(default)]
178    pub max_rounds: usize,
179}
180
181#[derive(Clone, Debug, Default, Serialize, Deserialize)]
182pub struct RejectionRules {
183    #[serde(default)]
184    pub terminal_on_any_reject: bool,
185}
186
187// ── Task Policy Rules (RFC-MACP-0012 Section 4.4) ──────────────────
188
189#[derive(Clone, Debug, Serialize, Deserialize, Default)]
190pub struct TaskPolicyRules {
191    #[serde(default)]
192    pub assignment: TaskAssignmentRules,
193    #[serde(default)]
194    pub completion: TaskCompletionRules,
195    #[serde(default)]
196    pub commitment: CommitmentRules,
197}
198
199#[derive(Clone, Debug, Default, Serialize, Deserialize)]
200pub struct TaskAssignmentRules {
201    #[serde(default)]
202    pub allow_reassignment_on_reject: bool,
203}
204
205#[derive(Clone, Debug, Default, Serialize, Deserialize)]
206pub struct TaskCompletionRules {
207    #[serde(default)]
208    pub require_output: bool,
209}
210
211// ── Handoff Policy Rules (RFC-MACP-0012 Section 4.5) ───────────────
212
213#[derive(Clone, Debug, Serialize, Deserialize, Default)]
214pub struct HandoffPolicyRules {
215    #[serde(default)]
216    pub acceptance: HandoffAcceptanceRules,
217    #[serde(default)]
218    pub commitment: CommitmentRules,
219}
220
221#[derive(Clone, Debug, Default, Serialize, Deserialize)]
222pub struct HandoffAcceptanceRules {
223    #[serde(default)]
224    pub implicit_accept_timeout_ms: u64,
225}
226
227// ── Quorum Policy Rules (RFC-MACP-0012 Section 4.2) ────────────────
228
229#[derive(Clone, Debug, Serialize, Deserialize, Default)]
230pub struct QuorumPolicyRules {
231    #[serde(default)]
232    pub threshold: QuorumThreshold,
233    #[serde(default)]
234    pub abstention: AbstentionRules,
235    #[serde(default)]
236    pub commitment: CommitmentRules,
237}
238
239/// Threshold rules for Quorum mode (distinct from `QuorumRules` used in Decision mode's `voting.quorum`).
240#[derive(Clone, Debug, Serialize, Deserialize)]
241pub struct QuorumThreshold {
242    #[serde(default = "default_threshold_type", rename = "type")]
243    pub threshold_type: String,
244    #[serde(default)]
245    pub value: f64,
246}
247
248impl Default for QuorumThreshold {
249    fn default() -> Self {
250        Self {
251            threshold_type: default_threshold_type(),
252            value: 0.0,
253        }
254    }
255}
256
257fn default_threshold_type() -> String {
258    "n_of_m".into()
259}
260
261/// The approval bar a Quorum Mode [`QuorumThreshold`] imposes on one session.
262///
263/// Produced only by [`QuorumThreshold::effective`], which is the **single**
264/// implementation of that rule. It lives in `macp-core` because two crates
265/// need it — `QuorumMode::effective_threshold` in `macp-modes` and
266/// `evaluate_quorum_commitment_outcome` in `macp-policy` — and when they each
267/// carried their own copy they disagreed: the mode truncated a fractional
268/// `value` (`0.5` → `0`) while the evaluator ceiled it (`0.5` → `1`), so one
269/// policy produced two different thresholds. RFC-MACP-0011 §7 forbids exactly
270/// that ("implementations MUST derive the same quorum state and the same
271/// commitment eligibility"). A third caller must call this, not re-derive it.
272///
273/// **Deliberately not `#[non_exhaustive]`**, unlike its neighbours in this
274/// crate ([`crate::error::MacpError`], [`crate::mode::ModeResponse`],
275/// [`crate::mode::MessageContext`], [`crate::session::Session`],
276/// [`super::PolicyDecision`], [`super::CommitmentMode`]). That attribute binds
277/// every crate except the defining one, so here it would force a `_` arm at
278/// exactly the two call sites — `QuorumMode::effective_threshold` in
279/// `macp-modes` and `evaluate_quorum_commitment_outcome` in `macp-policy` —
280/// whose compile-time exhaustiveness *is* the guarantee unifying this rule
281/// buys. A fail-closed `_` arm would be strictly worse for a governance
282/// kernel: a future variant would silently decline instead of failing to
283/// build, which is the same class of silent mis-handling as issue #145. Adding
284/// a variant later is not a silent break either — `enum_variant_added` is a
285/// major `cargo-semver-checks` lint and `release-plz.toml` sets
286/// `semver_check = true`, so it blocks the release PR. The residual cost is
287/// release coordination, not an undetected breakage.
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub enum EffectiveThreshold {
290    /// The rule imposes no bar (`value <= 0`, including the schema default),
291    /// so the caller keeps its own default.
292    ///
293    /// The two callers' defaults **differ**, and unifying them is out of scope
294    /// for the rounding fix: the mode falls back to the ApprovalRequest's
295    /// `required_approvals`, while the evaluator applies no threshold check at
296    /// all. See `ASSUMPTIONS.md`, "Quorum `threshold.value = 0`".
297    Inert,
298    /// This many approvals are required to seal a **positive** commitment.
299    /// Never zero: a bar of zero would be met before any ballot was cast.
300    Approvals(u32),
301    /// No number of approvals can satisfy the rule, so the session can seal no
302    /// positive commitment. Returned for any unrecognised `type` — which as of
303    /// RFC-MACP-0012 1.2.0-draft includes `weighted`, removed from
304    /// `quorum-rules.schema.json`'s enum and reserved by spec #110 because it
305    /// never had a weights vocabulary, an electorate rule, or a weighted
306    /// analogue of RFC-MACP-0011 §5's count-only termination arithmetic — and
307    /// for a `percentage` over an empty participant set.
308    ///
309    /// Registration refuses an unrecognised type
310    /// (`PolicyRegistry::validate_quorum_threshold`), so reaching that needs
311    /// a directly-constructed `PolicyDefinition`; the empty-participant-set
312    /// case is not catchable there, since registration has no participant
313    /// count, and `QuorumMode::on_session_start` blocks it instead. It
314    /// fails closed rather than silently reinterpreting the value as a raw
315    /// approval count, which is what the old shared `_` arm did.
316    Unsatisfiable,
317}
318
319impl QuorumThreshold {
320    /// Resolve this threshold against a session with `total_participants`
321    /// declared participants. See [`EffectiveThreshold`] for the contract —
322    /// **both** the mode and the policy evaluator must resolve through here.
323    ///
324    /// Rounding is **ceiling** (`0.5` of a participant is a whole participant,
325    /// and half a vote cannot approve anything), and the result has a floor of
326    /// one approval. That floor is what makes `T = 0` unreachable: at `T = 0`
327    /// a session is "ready to commit" with no ballot cast at all, and a
328    /// negative commitment then seals with zero approvals (issue #145).
329    ///
330    /// For `percentage`, RFC-MACP-0012 §4.2 (1.2.0-draft, spec #110) promoted
331    /// that rounding direction from a non-normative rationale into normative
332    /// text and pinned the arithmetic with it: the effective bar is
333    /// `ceil(value × declared_participant_count / 100)`, computed with **exact
334    /// integer arithmetic** — "implementations … MUST NOT use floating-point
335    /// division". The denominator is fixed at `SessionStart` and does not
336    /// shrink as ballots, abstentions included, are cast (§8's completion note;
337    /// RFC-MACP-0011 §5 rule 4a made that arguable).
338    pub fn effective(&self, total_participants: usize) -> EffectiveThreshold {
339        // `is_sign_negative` would mis-handle NaN and -0.0; comparing against
340        // the ordered predicate keeps NaN, 0.0 and negatives on one path.
341        if self.value.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
342            return EffectiveThreshold::Inert;
343        }
344        let required: f64 = match self.threshold_type.as_str() {
345            "percentage" => {
346                if total_participants == 0 {
347                    // Unreachable through the mode (`QuorumMode::on_session_start`
348                    // rejects an empty participant set) but reachable through a
349                    // direct evaluator call; a share of nobody is unmeetable.
350                    return EffectiveThreshold::Unsatisfiable;
351                }
352                // Exact integer ceiling division. §4.2 spells the equivalence
353                // as `(value × n + 99) div 100`; `div_ceil` is that, without
354                // the manual bias term. `value` is `f64` only because
355                // serde hands us a JSON number; the rule schema types it
356                // `integer` and registration refuses a fractional one
357                // (`PolicyRegistry::validate_quorum_threshold`), so the integer
358                // path below is the only one reachable through the registry.
359                //
360                // Dividing by 100 *first*, which this used to do, is not merely
361                // inelegant — it is wrong. `value / 100.0` is inexact in
362                // binary64 for most integer percentages, and the error survives
363                // the multiplication into the ceiling: `value: 7` over 100
364                // participants produced a bar of 8 where the exact rule gives
365                // 7, and `value: 28` over 25 participants produced 8 where the
366                // rule gives 7. Thirteen `(value, participants)` pairs diverge
367                // within `value ∈ 1..=100`, `participants ∈ 1..=100` alone.
368                //
369                // Float → integer casts saturate in Rust, and the arithmetic is
370                // saturating too, so an out-of-range `value` reachable only by
371                // constructing the struct directly yields an
372                // unmeetable-but-finite bar rather than panicking or wrapping.
373                if self.value.fract() == 0.0 {
374                    let scaled = (self.value as u128)
375                        .saturating_mul(total_participants as u128)
376                        .div_ceil(100);
377                    let required = scaled.clamp(1, u32::MAX as u128) as u32;
378                    return EffectiveThreshold::Approvals(required);
379                }
380                // A fractional `percentage` cannot be registered, so this is a
381                // directly-constructed descriptor. Multiply before dividing:
382                // that keeps the ceiling faithful where dividing first does
383                // not.
384                self.value * total_participants as f64 / 100.0
385            }
386            // `count` is this runtime's documented alias for `n_of_m`
387            // (`docs/policy.md`); the canonical schema enum omits it — spec
388            // #110 closed that enum against the alias for good (issue #98
389            // item 4) and this runtime's continued acceptance of it is a
390            // documented departure.
391            "n_of_m" | "count" => self.value,
392            _ => return EffectiveThreshold::Unsatisfiable,
393        };
394        // `as u32` saturates on overflow, so an absurd `value` becomes an
395        // unmeetable-but-finite bar rather than wrapping to a small one.
396        EffectiveThreshold::Approvals((required.ceil() as u32).max(1))
397    }
398}
399
400#[derive(Clone, Debug, Serialize, Deserialize)]
401pub struct AbstentionRules {
402    #[serde(default)]
403    pub counts_toward_quorum: bool,
404    #[serde(default = "default_interpretation")]
405    pub interpretation: String,
406}
407
408impl Default for AbstentionRules {
409    fn default() -> Self {
410        Self {
411            counts_toward_quorum: false,
412            interpretation: default_interpretation(),
413        }
414    }
415}
416
417fn default_interpretation() -> String {
418    "neutral".into()
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn decision_policy_rules_defaults() {
427        let rules = DecisionPolicyRules::default();
428        assert_eq!(rules.voting.algorithm, "none");
429        assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON);
430        assert_eq!(rules.voting.quorum.quorum_type, "count");
431        assert!(!rules.objection_handling.critical_severity_vetoes);
432        assert_eq!(rules.objection_handling.veto_threshold, 1);
433        assert_eq!(
434            rules.objection_handling.critical_objection_action,
435            CriticalObjectionAction::Deny
436        );
437        assert!(!rules.commitment.allow_decline_over_approval);
438        assert!(!rules.evaluation.required_before_voting);
439        assert!((rules.evaluation.minimum_confidence).abs() < f64::EPSILON);
440        assert_eq!(rules.commitment.authority, "initiator_only");
441        assert!(rules.commitment.designated_roles.is_empty());
442        assert!(!rules.commitment.require_vote_quorum);
443    }
444
445    #[test]
446    fn decision_policy_rules_deserialization() {
447        let json = serde_json::json!({
448            "voting": {
449                "algorithm": "majority",
450                "threshold": 0.6,
451                "quorum": { "type": "percentage", "value": 75.0 },
452                "weights": { "agent://fraud": 2.0, "agent://growth": 1.0 }
453            },
454            "objection_handling": {
455                "critical_severity_vetoes": true,
456                "veto_threshold": 2
457            },
458            "evaluation": {
459                "required_before_voting": true,
460                "minimum_confidence": 0.8
461            },
462            "commitment": {
463                "authority": "designated_role",
464                "designated_roles": ["agent://lead"],
465                "require_vote_quorum": true
466            }
467        });
468
469        let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap();
470        assert_eq!(rules.voting.algorithm, "majority");
471        assert!((rules.voting.threshold - 0.6).abs() < f64::EPSILON);
472        assert_eq!(rules.voting.quorum.quorum_type, "percentage");
473        assert!((rules.voting.quorum.value - 75.0).abs() < f64::EPSILON);
474        assert_eq!(*rules.voting.weights.get("agent://fraud").unwrap(), 2.0);
475        assert!(rules.objection_handling.critical_severity_vetoes);
476        assert_eq!(rules.objection_handling.veto_threshold, 2);
477        assert!(rules.evaluation.required_before_voting);
478        assert!((rules.evaluation.minimum_confidence - 0.8).abs() < f64::EPSILON);
479        assert_eq!(rules.commitment.authority, "designated_role");
480        assert_eq!(rules.commitment.designated_roles, vec!["agent://lead"]);
481        assert!(rules.commitment.require_vote_quorum);
482    }
483
484    #[test]
485    fn partial_deserialization_fills_defaults() {
486        let json = serde_json::json!({
487            "voting": { "algorithm": "unanimous" }
488        });
489        let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap();
490        assert_eq!(rules.voting.algorithm, "unanimous");
491        assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON);
492        assert!(!rules.objection_handling.critical_severity_vetoes);
493        assert_eq!(rules.objection_handling.veto_threshold, 1);
494    }
495
496    #[test]
497    fn proposal_policy_rules_defaults() {
498        let rules = ProposalPolicyRules::default();
499        assert_eq!(rules.acceptance.criterion, "all_parties");
500        assert_eq!(rules.counter_proposal.max_rounds, 0);
501        assert!(!rules.rejection.terminal_on_any_reject);
502        assert_eq!(rules.commitment.authority, "initiator_only");
503    }
504
505    #[test]
506    fn proposal_policy_rules_deserialization() {
507        let json = serde_json::json!({
508            "acceptance": { "criterion": "counterparty" },
509            "counter_proposal": { "max_rounds": 3 },
510            "rejection": { "terminal_on_any_reject": true },
511            "commitment": { "authority": "any_participant" }
512        });
513        let rules: ProposalPolicyRules = serde_json::from_value(json).unwrap();
514        assert_eq!(rules.acceptance.criterion, "counterparty");
515        assert_eq!(rules.counter_proposal.max_rounds, 3);
516        assert!(rules.rejection.terminal_on_any_reject);
517        assert_eq!(rules.commitment.authority, "any_participant");
518    }
519
520    #[test]
521    fn task_policy_rules_defaults() {
522        let rules = TaskPolicyRules::default();
523        assert!(!rules.assignment.allow_reassignment_on_reject);
524        assert!(!rules.completion.require_output);
525        assert_eq!(rules.commitment.authority, "initiator_only");
526    }
527
528    #[test]
529    fn task_policy_rules_deserialization() {
530        let json = serde_json::json!({
531            "assignment": { "allow_reassignment_on_reject": true },
532            "completion": { "require_output": true },
533            "commitment": { "authority": "initiator_only" }
534        });
535        let rules: TaskPolicyRules = serde_json::from_value(json).unwrap();
536        assert!(rules.assignment.allow_reassignment_on_reject);
537        assert!(rules.completion.require_output);
538    }
539
540    #[test]
541    fn handoff_policy_rules_defaults() {
542        let rules = HandoffPolicyRules::default();
543        assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 0);
544        assert_eq!(rules.commitment.authority, "initiator_only");
545    }
546
547    #[test]
548    fn handoff_policy_rules_deserialization() {
549        let json = serde_json::json!({
550            "acceptance": { "implicit_accept_timeout_ms": 5000 },
551            "commitment": { "authority": "any_participant" }
552        });
553        let rules: HandoffPolicyRules = serde_json::from_value(json).unwrap();
554        assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 5000);
555        assert_eq!(rules.commitment.authority, "any_participant");
556    }
557
558    #[test]
559    fn quorum_policy_rules_defaults() {
560        let rules = QuorumPolicyRules::default();
561        assert_eq!(rules.threshold.threshold_type, "n_of_m");
562        assert!((rules.threshold.value).abs() < f64::EPSILON);
563        assert!(!rules.abstention.counts_toward_quorum);
564        assert_eq!(rules.abstention.interpretation, "neutral");
565        assert_eq!(rules.commitment.authority, "initiator_only");
566    }
567
568    #[test]
569    fn effective_threshold_ceils_and_floors_at_one() {
570        let t = |kind: &str, value: f64| QuorumThreshold {
571            threshold_type: kind.into(),
572            value,
573        };
574        // Ceiling, not truncation — the divergence behind issue #145.
575        assert_eq!(
576            t("n_of_m", 0.5).effective(3),
577            EffectiveThreshold::Approvals(1)
578        );
579        assert_eq!(
580            t("count", 2.4).effective(3),
581            EffectiveThreshold::Approvals(3)
582        );
583        // Percentage is a share of the declared participants.
584        assert_eq!(
585            t("percentage", 50.0).effective(3),
586            EffectiveThreshold::Approvals(2)
587        );
588        // The floor keeps a bar of 0 unreachable.
589        assert_eq!(
590            t("percentage", 0.5).effective(3),
591            EffectiveThreshold::Approvals(1)
592        );
593        // Non-positive and NaN are inert; the caller keeps its own default.
594        assert_eq!(t("n_of_m", 0.0).effective(3), EffectiveThreshold::Inert);
595        assert_eq!(t("n_of_m", -1.0).effective(3), EffectiveThreshold::Inert);
596        assert_eq!(
597            t("n_of_m", f64::NAN).effective(3),
598            EffectiveThreshold::Inert
599        );
600        // Unimplemented and unknown types fail closed rather than being read
601        // as a raw approval count, and so does a share of nobody.
602        assert_eq!(
603            t("weighted", 2.0).effective(3),
604            EffectiveThreshold::Unsatisfiable
605        );
606        assert_eq!(
607            t("two_thirds", 2.0).effective(3),
608            EffectiveThreshold::Unsatisfiable
609        );
610        assert_eq!(
611            t("percentage", 50.0).effective(0),
612            EffectiveThreshold::Unsatisfiable
613        );
614        // An absurd value saturates instead of wrapping to a small bar.
615        assert_eq!(
616            t("n_of_m", 1e30).effective(3),
617            EffectiveThreshold::Approvals(u32::MAX)
618        );
619        assert_eq!(
620            t("percentage", 1e30).effective(3),
621            EffectiveThreshold::Approvals(u32::MAX)
622        );
623    }
624
625    /// RFC-MACP-0012 §4.2 (1.2.0-draft) pins the `percentage` bar at
626    /// `ceil(value × declared_participant_count / 100)` computed with exact
627    /// integer arithmetic and forbids floating-point division. Dividing by 100
628    /// first — what this used to do — is off by one wherever `value / 100.0`
629    /// rounds up in binary64 and the error survives into the ceiling.
630    #[test]
631    fn percentage_threshold_uses_exact_integer_ceiling_division() {
632        let pct = |value: f64| QuorumThreshold {
633            threshold_type: "percentage".into(),
634            value,
635        };
636        // The four cases the float path got wrong at small participant counts.
637        // Each of these returned one approval too many.
638        assert_eq!(pct(28.0).effective(25), EffectiveThreshold::Approvals(7));
639        assert_eq!(pct(14.0).effective(50), EffectiveThreshold::Approvals(7));
640        assert_eq!(pct(7.0).effective(100), EffectiveThreshold::Approvals(7));
641        assert_eq!(pct(68.0).effective(75), EffectiveThreshold::Approvals(51));
642
643        // RFC-MACP-0012 §5.2's worked examples, which are arithmetic only under
644        // ceiling.
645        assert_eq!(pct(67.0).effective(3), EffectiveThreshold::Approvals(3));
646        assert_eq!(pct(51.0).effective(200), EffectiveThreshold::Approvals(102));
647        assert_eq!(pct(50.0).effective(3), EffectiveThreshold::Approvals(2));
648
649        // Exact multiples are not rounded up past themselves.
650        assert_eq!(pct(50.0).effective(4), EffectiveThreshold::Approvals(2));
651        assert_eq!(pct(100.0).effective(7), EffectiveThreshold::Approvals(7));
652        assert_eq!(pct(25.0).effective(8), EffectiveThreshold::Approvals(2));
653
654        // Exhaustive against the reference formula over the whole registrable
655        // domain of `value`, for every participant count a session plausibly
656        // carries. This is the assertion that would have caught the old bug.
657        // Written as an explicit quotient-plus-remainder rather than reusing
658        // `div_ceil`, so the oracle stays independent of the implementation.
659        for value in 1..=100u128 {
660            for participants in 1..=500usize {
661                let product = value * participants as u128;
662                let expected = product / 100 + u128::from(!product.is_multiple_of(100));
663                assert_eq!(
664                    pct(value as f64).effective(participants),
665                    EffectiveThreshold::Approvals(expected as u32),
666                    "value {value} over {participants} participants"
667                );
668            }
669        }
670    }
671
672    #[test]
673    fn quorum_policy_rules_deserialization() {
674        let json = serde_json::json!({
675            "threshold": { "type": "percentage", "value": 75.0 },
676            "abstention": { "counts_toward_quorum": true, "interpretation": "implicit_reject" },
677            "commitment": { "authority": "initiator_only" }
678        });
679        let rules: QuorumPolicyRules = serde_json::from_value(json).unwrap();
680        assert_eq!(rules.threshold.threshold_type, "percentage");
681        assert!((rules.threshold.value - 75.0).abs() < f64::EPSILON);
682        assert!(rules.abstention.counts_toward_quorum);
683        assert_eq!(rules.abstention.interpretation, "implicit_reject");
684    }
685}