Skip to main content

macp_modes/mode/
util.rs

1use macp_core::error::MacpError;
2use macp_core::session::Session;
3use macp_pb::pb::CommitmentPayload;
4use prost::Message;
5
6pub fn decode_commitment_payload(payload: &[u8]) -> Result<CommitmentPayload, MacpError> {
7    CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
8}
9
10pub fn validate_commitment_payload_for_session(
11    session: &Session,
12    payload: &[u8],
13) -> Result<CommitmentPayload, MacpError> {
14    let commitment = decode_commitment_payload(payload)?;
15
16    if commitment.commitment_id.trim().is_empty()
17        || commitment.action.trim().is_empty()
18        || commitment.authority_scope.trim().is_empty()
19        || commitment.reason.trim().is_empty()
20    {
21        return Err(MacpError::InvalidPayload);
22    }
23
24    if commitment.mode_version != session.mode_version
25        || commitment.configuration_version != session.configuration_version
26    {
27        return Err(MacpError::InvalidPayload);
28    }
29
30    if !session.policy_version.is_empty() && commitment.policy_version != session.policy_version {
31        return Err(MacpError::InvalidPayload);
32    }
33
34    // RFC-MACP-0001 §7.3.1: if this commitment supersedes a prior one, the
35    // reference must be structurally well-formed. Supersession is inherently
36    // cross-session, so the kernel checks only well-formedness here (and
37    // authority, separately) — it does NOT verify the referenced commitment
38    // exists, was sealed, or is unforked. Those are consumer governance.
39    if let Some(ref sup) = commitment.supersedes {
40        if sup.session_id.trim().is_empty() || sup.commitment_hash.trim().is_empty() {
41            return Err(MacpError::InvalidPayload);
42        }
43    }
44
45    // Validate outcome_positive consistency with action (RFC-0001 §7.3)
46    validate_outcome_positive(&commitment)?;
47
48    Ok(commitment)
49}
50
51/// Validate that `outcome_positive` is consistent with the `action` field.
52/// Actions ending in `rejected`, `failed`, or `declined` must have `outcome_positive = false`.
53/// Actions ending in `selected`, `accepted`, `completed`, or `approved` must have `outcome_positive = true`.
54fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> {
55    let action = commitment.action.as_str();
56    let negative_actions = ["rejected", "failed", "declined"];
57    let positive_actions = ["selected", "accepted", "completed", "approved"];
58
59    let is_negative = negative_actions
60        .iter()
61        .any(|suffix| action.ends_with(suffix));
62    let is_positive = positive_actions
63        .iter()
64        .any(|suffix| action.ends_with(suffix));
65
66    if is_negative && commitment.outcome_positive {
67        return Err(MacpError::InvalidPayload);
68    }
69    if is_positive && !commitment.outcome_positive {
70        return Err(MacpError::InvalidPayload);
71    }
72    Ok(())
73}
74
75pub fn is_declared_participant(participants: &[String], sender: &str) -> bool {
76    participants.iter().any(|participant| participant == sender)
77}
78
79/// Check whether the sender is authorized to commit per the policy's `commitment.authority` rule.
80///
81/// RFC-MACP-0012 §4: the `commitment` rule group controls who can emit a Commitment
82/// envelope. If no policy is bound, defaults to initiator-only (RFC-MACP-0001 §7.3).
83pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> {
84    if let Some(ref policy) = session.policy_definition {
85        let rules: macp_core::policy::rules::CommitmentRules =
86            extract_commitment_rules(&policy.rules);
87        match rules.authority.as_str() {
88            "any_participant" => {
89                if sender == session.initiator_sender
90                    || is_declared_participant(&session.participants, sender)
91                {
92                    Ok(())
93                } else {
94                    Err(MacpError::Forbidden)
95                }
96            }
97            "designated_role" => {
98                if rules.designated_roles.iter().any(|r| r == sender) {
99                    Ok(())
100                } else {
101                    Err(MacpError::Forbidden)
102                }
103            }
104            _ => {
105                // "initiator_only" (default)
106                if sender == session.initiator_sender {
107                    Ok(())
108                } else {
109                    Err(MacpError::Forbidden)
110                }
111            }
112        }
113    } else {
114        // No policy bound — default to initiator-only
115        if sender == session.initiator_sender {
116            Ok(())
117        } else {
118            Err(MacpError::Forbidden)
119        }
120    }
121}
122
123/// Extract the `commitment` section from any mode's policy rules JSON.
124/// All RFC mode schemas include a `commitment` sub-object with `authority` and `designated_roles`.
125fn extract_commitment_rules(
126    rules: &serde_json::Value,
127) -> macp_core::policy::rules::CommitmentRules {
128    rules
129        .get("commitment")
130        .and_then(|c| serde_json::from_value(c.clone()).ok())
131        .unwrap_or_default()
132}
133
134pub fn participants_all_accept(
135    participants: &[String],
136    accepts: &std::collections::BTreeMap<String, String>,
137    proposal_id: &str,
138) -> bool {
139    !participants.is_empty()
140        && participants
141            .iter()
142            .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id))
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use macp_pb::pb::CommitmentPayload;
149
150    fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload {
151        CommitmentPayload {
152            commitment_id: "c1".into(),
153            action: action.into(),
154            authority_scope: "scope".into(),
155            reason: "reason".into(),
156            mode_version: "1.0.0".into(),
157            policy_version: String::new(),
158            configuration_version: "cfg-1".into(),
159            outcome_positive,
160            supersedes: None,
161        }
162    }
163
164    // --- supersedes structural validation (RFC-MACP-0001 §7.3.1) ---
165
166    fn session_for_commitment() -> Session {
167        use std::collections::{HashMap, HashSet};
168        Session {
169            session_id: "s1".into(),
170            state: macp_core::session::SessionState::Open,
171            ttl_expiry: i64::MAX,
172            ttl_ms: 60_000,
173            started_at_unix_ms: 0,
174            resolution: None,
175            mode: "macp.mode.decision.v1".into(),
176            mode_state: vec![],
177            participants: vec![],
178            seen_message_ids: HashSet::new(),
179            intent: String::new(),
180            mode_version: "1.0.0".into(),
181            configuration_version: "cfg-1".into(),
182            policy_version: String::new(),
183            context_id: String::new(),
184            extensions: HashMap::new(),
185            roots: vec![],
186            initiator_sender: "agent://a".into(),
187            participant_message_counts: HashMap::new(),
188            participant_last_seen: HashMap::new(),
189            policy_definition: None,
190            suspended_at_ms: None,
191            accumulated_suspended_ms: 0,
192        }
193    }
194
195    #[test]
196    fn well_formed_supersedes_is_accepted() {
197        let session = session_for_commitment();
198        let mut c = make_commitment("decision.selected", true);
199        c.supersedes = Some(macp_pb::pb::CommitmentRef {
200            session_id: "prior-session".into(),
201            commitment_hash: "abc123".into(),
202        });
203        assert!(validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_ok());
204    }
205
206    #[test]
207    fn malformed_supersedes_is_rejected() {
208        let session = session_for_commitment();
209        for bad in [("", "abc123"), ("prior-session", ""), ("  ", "abc123")] {
210            let mut c = make_commitment("decision.selected", true);
211            c.supersedes = Some(macp_pb::pb::CommitmentRef {
212                session_id: bad.0.into(),
213                commitment_hash: bad.1.into(),
214            });
215            assert!(
216                validate_commitment_payload_for_session(&session, &c.encode_to_vec()).is_err(),
217                "expected rejection for supersedes {bad:?}"
218            );
219        }
220    }
221
222    // --- outcome_positive validation: RFC-defined positive actions ---
223
224    #[test]
225    fn decision_selected_positive_ok() {
226        assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok());
227    }
228
229    #[test]
230    fn decision_selected_negative_rejected() {
231        assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err());
232    }
233
234    #[test]
235    fn decision_rejected_negative_ok() {
236        assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok());
237    }
238
239    #[test]
240    fn decision_rejected_positive_rejected() {
241        assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err());
242    }
243
244    #[test]
245    fn proposal_accepted_positive_ok() {
246        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok());
247    }
248
249    #[test]
250    fn proposal_accepted_negative_rejected() {
251        assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err());
252    }
253
254    #[test]
255    fn proposal_rejected_negative_ok() {
256        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok());
257    }
258
259    #[test]
260    fn proposal_rejected_positive_rejected() {
261        assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err());
262    }
263
264    #[test]
265    fn task_completed_positive_ok() {
266        assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok());
267    }
268
269    #[test]
270    fn task_completed_negative_rejected() {
271        assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err());
272    }
273
274    #[test]
275    fn task_failed_negative_ok() {
276        assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok());
277    }
278
279    #[test]
280    fn task_failed_positive_rejected() {
281        assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err());
282    }
283
284    #[test]
285    fn handoff_accepted_positive_ok() {
286        assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok());
287    }
288
289    #[test]
290    fn handoff_declined_negative_ok() {
291        assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok());
292    }
293
294    #[test]
295    fn handoff_declined_positive_rejected() {
296        assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err());
297    }
298
299    #[test]
300    fn quorum_approved_positive_ok() {
301        assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok());
302    }
303
304    #[test]
305    fn quorum_rejected_negative_ok() {
306        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok());
307    }
308
309    #[test]
310    fn quorum_rejected_positive_rejected() {
311        assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err());
312    }
313
314    #[test]
315    fn custom_action_no_known_suffix_any_outcome_ok() {
316        // Actions without recognized suffixes pass validation regardless of outcome_positive
317        assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok());
318        assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok());
319    }
320}