Skip to main content

macp_core/policy/
mod.rs

1//! Policy vocabulary and the pluggable evaluation trait.
2//!
3//! Core holds the types modes and the kernel must name: the policy
4//! definition/decision/error, the per-mode [`rules`] schemas (read by modes to
5//! drive policy-parameterized behavior and by evaluators to decide commitments),
6//! and the [`PolicyEvaluator`] trait that modes call through. The concrete
7//! default evaluator lives in the `macp-policy` crate; a third party can supply
8//! its own `PolicyEvaluator` and inject it without forking the kernel.
9
10pub mod rules;
11
12use crate::decision::DecisionState;
13use serde::{Deserialize, Serialize};
14
15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct PolicyDefinition {
17    pub policy_id: String,
18    pub mode: String,
19    pub description: String,
20    pub rules: serde_json::Value,
21    pub schema_version: u32,
22}
23
24/// `#[non_exhaustive]`: consumers MUST treat any non-`Allow` decision as a
25/// denial (fail closed). Never `if let Deny` — that fails open on new variants.
26#[non_exhaustive]
27#[derive(Clone, Debug, PartialEq)]
28pub enum PolicyDecision {
29    Allow { reasons: Vec<String> },
30    Deny { reasons: Vec<String> },
31}
32
33#[non_exhaustive]
34#[derive(Clone, Debug, PartialEq)]
35pub enum PolicyError {
36    UnknownPolicy(String),
37    InvalidDefinition(String),
38    PolicyDenied(String),
39}
40
41impl std::fmt::Display for PolicyError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            PolicyError::UnknownPolicy(id) => write!(f, "unknown policy: {}", id),
45            PolicyError::InvalidDefinition(msg) => write!(f, "invalid policy definition: {}", msg),
46            PolicyError::PolicyDenied(reason) => write!(f, "policy denied: {}", reason),
47        }
48    }
49}
50
51impl std::error::Error for PolicyError {}
52
53/// Commitment rules shared across all mode policy schemas (RFC-MACP-0012).
54///
55/// This `commitment` sub-object appears in every mode's rule schema and is read
56/// directly by the modes (to authorize who may emit a `Commitment`), so it
57/// lives in core rather than in `macp-policy`.
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub struct CommitmentRules {
60    #[serde(default = "default_authority")]
61    pub authority: String,
62    #[serde(default)]
63    pub designated_roles: Vec<String>,
64    #[serde(default)]
65    pub require_vote_quorum: bool,
66    /// When `true`, an authorized initiator may finalize a *decline*
67    /// (`outcome_positive = false`) even when the vote passed the approval
68    /// threshold — the "executive veto" pattern. Defaults to `false`, which
69    /// preserves the conservative behavior: a passing vote only authorizes a
70    /// positive commitment. See RFC-MACP-0007 §6 (negative committed outcomes).
71    #[serde(default)]
72    pub allow_decline_over_approval: bool,
73}
74
75impl Default for CommitmentRules {
76    fn default() -> Self {
77        Self {
78            authority: default_authority(),
79            designated_roles: Vec::new(),
80            require_vote_quorum: false,
81            allow_decline_over_approval: false,
82        }
83    }
84}
85
86fn default_authority() -> String {
87    "initiator_only".into()
88}
89
90/// Extract the `commitment` section from any mode's policy rules JSON.
91/// All RFC mode schemas include a `commitment` sub-object with `authority` and
92/// `designated_roles`.
93pub fn extract_commitment_rules(rules: &serde_json::Value) -> CommitmentRules {
94    rules
95        .get("commitment")
96        .and_then(|c| serde_json::from_value(c.clone()).ok())
97        .unwrap_or_default()
98}
99
100/// Everything a policy evaluator may consult when gating a commitment.
101///
102/// Built by the mode at commitment time. `outcome_positive` comes from the
103/// validated `CommitmentPayload` and makes every mode's evaluation
104/// outcome-aware: a negative (decline) commitment is a legitimate terminal
105/// outcome and must not be denied by checks that only make sense for positive
106/// outcomes (RFC-MACP-0007 §6 and the schema_version 2 decline semantics).
107pub struct CommitmentContext<'a> {
108    pub policy: &'a PolicyDefinition,
109    pub participants: &'a [String],
110    pub outcome_positive: bool,
111    pub mode: CommitmentMode<'a>,
112}
113
114/// Per-mode accumulated state relevant to commitment evaluation.
115///
116/// Carries exactly the data each mode already computes: `DecisionState` is a
117/// core domain type (passed whole); the other modes summarize their internal
118/// state into scalars. `#[non_exhaustive]`: evaluators must carry a wildcard
119/// arm and treat unknown modes as a denial (fail closed).
120#[non_exhaustive]
121pub enum CommitmentMode<'a> {
122    Decision {
123        state: &'a DecisionState,
124    },
125    Proposal {
126        counter_proposal_count: usize,
127    },
128    Task {
129        has_output: bool,
130    },
131    Handoff,
132    Quorum {
133        approve_count: usize,
134        reject_count: usize,
135        abstain_count: usize,
136    },
137}
138
139/// Governance policy evaluation at commitment time.
140///
141/// The runtime resolves a [`PolicyDefinition`] at `SessionStart` and stores it
142/// on the session; at commitment time a mode builds a [`CommitmentContext`]
143/// and calls [`PolicyEvaluator::evaluate_commitment`]. The default
144/// implementation lives in `macp-policy` (`macp_policy::DefaultPolicyEvaluator`);
145/// consumers may provide their own.
146///
147/// The per-mode methods are deprecated shims kept for one release; they build
148/// a `CommitmentContext` and delegate to `evaluate_commitment`.
149pub trait PolicyEvaluator: Send + Sync {
150    /// Single evaluation entry point. Only an explicit
151    /// [`PolicyDecision::Allow`] permits the commitment — callers must treat
152    /// any other decision as a denial (fail closed).
153    fn evaluate_commitment(&self, ctx: &CommitmentContext<'_>) -> PolicyDecision;
154
155    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
156    fn evaluate_decision_commitment(
157        &self,
158        policy: &PolicyDefinition,
159        state: &DecisionState,
160        participants: &[String],
161    ) -> PolicyDecision {
162        self.evaluate_commitment(&CommitmentContext {
163            policy,
164            participants,
165            outcome_positive: true,
166            mode: CommitmentMode::Decision { state },
167        })
168    }
169
170    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
171    fn evaluate_decision_commitment_outcome(
172        &self,
173        policy: &PolicyDefinition,
174        state: &DecisionState,
175        participants: &[String],
176        outcome_positive: bool,
177    ) -> PolicyDecision {
178        self.evaluate_commitment(&CommitmentContext {
179            policy,
180            participants,
181            outcome_positive,
182            mode: CommitmentMode::Decision { state },
183        })
184    }
185
186    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
187    fn evaluate_proposal_commitment(
188        &self,
189        policy: &PolicyDefinition,
190        counter_proposal_count: usize,
191    ) -> PolicyDecision {
192        self.evaluate_commitment(&CommitmentContext {
193            policy,
194            participants: &[],
195            outcome_positive: true,
196            mode: CommitmentMode::Proposal {
197                counter_proposal_count,
198            },
199        })
200    }
201
202    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
203    fn evaluate_task_commitment(
204        &self,
205        policy: &PolicyDefinition,
206        has_output: bool,
207    ) -> PolicyDecision {
208        self.evaluate_commitment(&CommitmentContext {
209            policy,
210            participants: &[],
211            outcome_positive: true,
212            mode: CommitmentMode::Task { has_output },
213        })
214    }
215
216    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
217    fn evaluate_handoff_commitment(&self, policy: &PolicyDefinition) -> PolicyDecision {
218        self.evaluate_commitment(&CommitmentContext {
219            policy,
220            participants: &[],
221            outcome_positive: true,
222            mode: CommitmentMode::Handoff,
223        })
224    }
225
226    #[deprecated(note = "build a CommitmentContext and call evaluate_commitment")]
227    fn evaluate_quorum_commitment(
228        &self,
229        policy: &PolicyDefinition,
230        approve_count: usize,
231        reject_count: usize,
232        abstain_count: usize,
233        total_participants: usize,
234    ) -> PolicyDecision {
235        // The legacy signature carried an explicit participant total; the
236        // context derives it from `participants`, which the shim cannot
237        // reconstruct — evaluators needing the total should count ballots or
238        // use `participants.len()`. Legacy callers are inside this workspace
239        // only and have been migrated.
240        let _ = total_participants;
241        self.evaluate_commitment(&CommitmentContext {
242            policy,
243            participants: &[],
244            outcome_positive: true,
245            mode: CommitmentMode::Quorum {
246                approve_count,
247                reject_count,
248                abstain_count,
249            },
250        })
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn policy_error_display() {
260        let e = PolicyError::UnknownPolicy("p1".into());
261        assert_eq!(e.to_string(), "unknown policy: p1");
262
263        let e = PolicyError::InvalidDefinition("bad".into());
264        assert_eq!(e.to_string(), "invalid policy definition: bad");
265
266        let e = PolicyError::PolicyDenied("nope".into());
267        assert_eq!(e.to_string(), "policy denied: nope");
268    }
269
270    #[test]
271    fn policy_definition_serialization_round_trip() {
272        let def = PolicyDefinition {
273            policy_id: "test".into(),
274            mode: "*".into(),
275            description: "test policy".into(),
276            rules: serde_json::json!({"voting": {"algorithm": "none"}}),
277            schema_version: 1,
278        };
279        let json = serde_json::to_string(&def).unwrap();
280        let parsed: PolicyDefinition = serde_json::from_str(&json).unwrap();
281        assert_eq!(parsed.policy_id, "test");
282        assert_eq!(parsed.schema_version, 1);
283    }
284
285    #[test]
286    fn commitment_rules_default_is_initiator_only() {
287        let rules = CommitmentRules::default();
288        assert_eq!(rules.authority, "initiator_only");
289        assert!(rules.designated_roles.is_empty());
290        assert!(!rules.require_vote_quorum);
291    }
292
293    #[test]
294    fn extract_commitment_rules_reads_nested_object() {
295        let rules = serde_json::json!({
296            "commitment": { "authority": "designated_role", "designated_roles": ["agent://lead"] }
297        });
298        let parsed = extract_commitment_rules(&rules);
299        assert_eq!(parsed.authority, "designated_role");
300        assert_eq!(parsed.designated_roles, vec!["agent://lead".to_string()]);
301    }
302}