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 `type: "weighted"` (unimplemented
303    /// here — `threshold.value` is typed `integer` by
304    /// `quorum-rules.schema.json`, so a weighted sum is not expressible and
305    /// per-participant quorum weights are not modelled), for any unrecognised
306    /// `type`, and for a `percentage` over an empty participant set.
307    ///
308    /// Registration refuses the first two
309    /// (`PolicyRegistry::validate_quorum_threshold`), so reaching those needs
310    /// a directly-constructed `PolicyDefinition`; the empty-participant-set
311    /// case is not catchable there, since registration has no participant
312    /// count, and `QuorumMode::on_session_start` blocks it instead. It
313    /// fails closed rather than silently reinterpreting the value as a raw
314    /// approval count, which is what the old shared `_` arm did.
315    Unsatisfiable,
316}
317
318impl QuorumThreshold {
319    /// Resolve this threshold against a session with `total_participants`
320    /// declared participants. See [`EffectiveThreshold`] for the contract —
321    /// **both** the mode and the policy evaluator must resolve through here.
322    ///
323    /// Rounding is **ceiling** (`0.5` of a participant is a whole participant,
324    /// and half a vote cannot approve anything), and the result has a floor of
325    /// one approval. That floor is what makes `T = 0` unreachable: at `T = 0`
326    /// a session is "ready to commit" with no ballot cast at all, and a
327    /// negative commitment then seals with zero approvals (issue #145).
328    pub fn effective(&self, total_participants: usize) -> EffectiveThreshold {
329        // `is_sign_negative` would mis-handle NaN and -0.0; comparing against
330        // the ordered predicate keeps NaN, 0.0 and negatives on one path.
331        if self.value.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
332            return EffectiveThreshold::Inert;
333        }
334        let required: f64 = match self.threshold_type.as_str() {
335            "percentage" => {
336                if total_participants == 0 {
337                    // Unreachable through the mode (`QuorumMode::on_session_start`
338                    // rejects an empty participant set) but reachable through a
339                    // direct evaluator call; a share of nobody is unmeetable.
340                    return EffectiveThreshold::Unsatisfiable;
341                }
342                (self.value / 100.0) * total_participants as f64
343            }
344            // `count` is this runtime's documented alias for `n_of_m`
345            // (`docs/policy.md`); the canonical schema enum omits it and the
346            // gap is tracked as spec issue #98.
347            "n_of_m" | "count" => self.value,
348            _ => return EffectiveThreshold::Unsatisfiable,
349        };
350        // `as u32` saturates on overflow, so an absurd `value` becomes an
351        // unmeetable-but-finite bar rather than wrapping to a small one.
352        EffectiveThreshold::Approvals((required.ceil() as u32).max(1))
353    }
354}
355
356#[derive(Clone, Debug, Serialize, Deserialize)]
357pub struct AbstentionRules {
358    #[serde(default)]
359    pub counts_toward_quorum: bool,
360    #[serde(default = "default_interpretation")]
361    pub interpretation: String,
362}
363
364impl Default for AbstentionRules {
365    fn default() -> Self {
366        Self {
367            counts_toward_quorum: false,
368            interpretation: default_interpretation(),
369        }
370    }
371}
372
373fn default_interpretation() -> String {
374    "neutral".into()
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn decision_policy_rules_defaults() {
383        let rules = DecisionPolicyRules::default();
384        assert_eq!(rules.voting.algorithm, "none");
385        assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON);
386        assert_eq!(rules.voting.quorum.quorum_type, "count");
387        assert!(!rules.objection_handling.critical_severity_vetoes);
388        assert_eq!(rules.objection_handling.veto_threshold, 1);
389        assert_eq!(
390            rules.objection_handling.critical_objection_action,
391            CriticalObjectionAction::Deny
392        );
393        assert!(!rules.commitment.allow_decline_over_approval);
394        assert!(!rules.evaluation.required_before_voting);
395        assert!((rules.evaluation.minimum_confidence).abs() < f64::EPSILON);
396        assert_eq!(rules.commitment.authority, "initiator_only");
397        assert!(rules.commitment.designated_roles.is_empty());
398        assert!(!rules.commitment.require_vote_quorum);
399    }
400
401    #[test]
402    fn decision_policy_rules_deserialization() {
403        let json = serde_json::json!({
404            "voting": {
405                "algorithm": "majority",
406                "threshold": 0.6,
407                "quorum": { "type": "percentage", "value": 75.0 },
408                "weights": { "agent://fraud": 2.0, "agent://growth": 1.0 }
409            },
410            "objection_handling": {
411                "critical_severity_vetoes": true,
412                "veto_threshold": 2
413            },
414            "evaluation": {
415                "required_before_voting": true,
416                "minimum_confidence": 0.8
417            },
418            "commitment": {
419                "authority": "designated_role",
420                "designated_roles": ["agent://lead"],
421                "require_vote_quorum": true
422            }
423        });
424
425        let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap();
426        assert_eq!(rules.voting.algorithm, "majority");
427        assert!((rules.voting.threshold - 0.6).abs() < f64::EPSILON);
428        assert_eq!(rules.voting.quorum.quorum_type, "percentage");
429        assert!((rules.voting.quorum.value - 75.0).abs() < f64::EPSILON);
430        assert_eq!(*rules.voting.weights.get("agent://fraud").unwrap(), 2.0);
431        assert!(rules.objection_handling.critical_severity_vetoes);
432        assert_eq!(rules.objection_handling.veto_threshold, 2);
433        assert!(rules.evaluation.required_before_voting);
434        assert!((rules.evaluation.minimum_confidence - 0.8).abs() < f64::EPSILON);
435        assert_eq!(rules.commitment.authority, "designated_role");
436        assert_eq!(rules.commitment.designated_roles, vec!["agent://lead"]);
437        assert!(rules.commitment.require_vote_quorum);
438    }
439
440    #[test]
441    fn partial_deserialization_fills_defaults() {
442        let json = serde_json::json!({
443            "voting": { "algorithm": "unanimous" }
444        });
445        let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap();
446        assert_eq!(rules.voting.algorithm, "unanimous");
447        assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON);
448        assert!(!rules.objection_handling.critical_severity_vetoes);
449        assert_eq!(rules.objection_handling.veto_threshold, 1);
450    }
451
452    #[test]
453    fn proposal_policy_rules_defaults() {
454        let rules = ProposalPolicyRules::default();
455        assert_eq!(rules.acceptance.criterion, "all_parties");
456        assert_eq!(rules.counter_proposal.max_rounds, 0);
457        assert!(!rules.rejection.terminal_on_any_reject);
458        assert_eq!(rules.commitment.authority, "initiator_only");
459    }
460
461    #[test]
462    fn proposal_policy_rules_deserialization() {
463        let json = serde_json::json!({
464            "acceptance": { "criterion": "counterparty" },
465            "counter_proposal": { "max_rounds": 3 },
466            "rejection": { "terminal_on_any_reject": true },
467            "commitment": { "authority": "any_participant" }
468        });
469        let rules: ProposalPolicyRules = serde_json::from_value(json).unwrap();
470        assert_eq!(rules.acceptance.criterion, "counterparty");
471        assert_eq!(rules.counter_proposal.max_rounds, 3);
472        assert!(rules.rejection.terminal_on_any_reject);
473        assert_eq!(rules.commitment.authority, "any_participant");
474    }
475
476    #[test]
477    fn task_policy_rules_defaults() {
478        let rules = TaskPolicyRules::default();
479        assert!(!rules.assignment.allow_reassignment_on_reject);
480        assert!(!rules.completion.require_output);
481        assert_eq!(rules.commitment.authority, "initiator_only");
482    }
483
484    #[test]
485    fn task_policy_rules_deserialization() {
486        let json = serde_json::json!({
487            "assignment": { "allow_reassignment_on_reject": true },
488            "completion": { "require_output": true },
489            "commitment": { "authority": "initiator_only" }
490        });
491        let rules: TaskPolicyRules = serde_json::from_value(json).unwrap();
492        assert!(rules.assignment.allow_reassignment_on_reject);
493        assert!(rules.completion.require_output);
494    }
495
496    #[test]
497    fn handoff_policy_rules_defaults() {
498        let rules = HandoffPolicyRules::default();
499        assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 0);
500        assert_eq!(rules.commitment.authority, "initiator_only");
501    }
502
503    #[test]
504    fn handoff_policy_rules_deserialization() {
505        let json = serde_json::json!({
506            "acceptance": { "implicit_accept_timeout_ms": 5000 },
507            "commitment": { "authority": "any_participant" }
508        });
509        let rules: HandoffPolicyRules = serde_json::from_value(json).unwrap();
510        assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 5000);
511        assert_eq!(rules.commitment.authority, "any_participant");
512    }
513
514    #[test]
515    fn quorum_policy_rules_defaults() {
516        let rules = QuorumPolicyRules::default();
517        assert_eq!(rules.threshold.threshold_type, "n_of_m");
518        assert!((rules.threshold.value).abs() < f64::EPSILON);
519        assert!(!rules.abstention.counts_toward_quorum);
520        assert_eq!(rules.abstention.interpretation, "neutral");
521        assert_eq!(rules.commitment.authority, "initiator_only");
522    }
523
524    #[test]
525    fn effective_threshold_ceils_and_floors_at_one() {
526        let t = |kind: &str, value: f64| QuorumThreshold {
527            threshold_type: kind.into(),
528            value,
529        };
530        // Ceiling, not truncation — the divergence behind issue #145.
531        assert_eq!(
532            t("n_of_m", 0.5).effective(3),
533            EffectiveThreshold::Approvals(1)
534        );
535        assert_eq!(
536            t("count", 2.4).effective(3),
537            EffectiveThreshold::Approvals(3)
538        );
539        // Percentage is a share of the declared participants.
540        assert_eq!(
541            t("percentage", 50.0).effective(3),
542            EffectiveThreshold::Approvals(2)
543        );
544        // The floor keeps a bar of 0 unreachable.
545        assert_eq!(
546            t("percentage", 0.5).effective(3),
547            EffectiveThreshold::Approvals(1)
548        );
549        // Non-positive and NaN are inert; the caller keeps its own default.
550        assert_eq!(t("n_of_m", 0.0).effective(3), EffectiveThreshold::Inert);
551        assert_eq!(t("n_of_m", -1.0).effective(3), EffectiveThreshold::Inert);
552        assert_eq!(
553            t("n_of_m", f64::NAN).effective(3),
554            EffectiveThreshold::Inert
555        );
556        // Unimplemented and unknown types fail closed rather than being read
557        // as a raw approval count, and so does a share of nobody.
558        assert_eq!(
559            t("weighted", 2.0).effective(3),
560            EffectiveThreshold::Unsatisfiable
561        );
562        assert_eq!(
563            t("two_thirds", 2.0).effective(3),
564            EffectiveThreshold::Unsatisfiable
565        );
566        assert_eq!(
567            t("percentage", 50.0).effective(0),
568            EffectiveThreshold::Unsatisfiable
569        );
570        // An absurd value saturates instead of wrapping to a small bar.
571        assert_eq!(
572            t("n_of_m", 1e30).effective(3),
573            EffectiveThreshold::Approvals(u32::MAX)
574        );
575    }
576
577    #[test]
578    fn quorum_policy_rules_deserialization() {
579        let json = serde_json::json!({
580            "threshold": { "type": "percentage", "value": 75.0 },
581            "abstention": { "counts_toward_quorum": true, "interpretation": "implicit_reject" },
582            "commitment": { "authority": "initiator_only" }
583        });
584        let rules: QuorumPolicyRules = serde_json::from_value(json).unwrap();
585        assert_eq!(rules.threshold.threshold_type, "percentage");
586        assert!((rules.threshold.value - 75.0).abs() < f64::EPSILON);
587        assert!(rules.abstention.counts_toward_quorum);
588        assert_eq!(rules.abstention.interpretation, "implicit_reject");
589    }
590}