Skip to main content

macp_modes/mode/
proposal.rs

1use crate::mode::util::{
2    check_commitment_authority, is_declared_participant, participants_all_accept,
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::proposal_pb::{
10    AcceptPayload, CounterProposalPayload, ProposalPayload, RejectPayload, WithdrawPayload,
11};
12use prost::Message;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub enum ProposalDisposition {
18    Live,
19    Withdrawn,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
23pub enum ProposalPhase {
24    #[default]
25    Negotiating,
26    Converged,
27    TerminalRejected,
28    Committed,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProposalRecord {
33    pub proposal_id: String,
34    pub title: String,
35    pub summary: String,
36    pub details: Vec<u8>,
37    pub tags: Vec<String>,
38    pub proposer: String,
39    pub supersedes_proposal_id: Option<String>,
40    pub disposition: ProposalDisposition,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TerminalRejectRecord {
45    pub proposal_id: String,
46    pub sender: String,
47    pub reason: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RejectRecord {
52    pub proposal_id: String,
53    pub sender: String,
54    pub reason: String,
55    pub terminal: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct ProposalState {
60    pub proposals: BTreeMap<String, ProposalRecord>,
61    pub accepts: BTreeMap<String, String>,
62    pub terminal_rejections: Vec<TerminalRejectRecord>,
63    /// All rejections (terminal and non-terminal) for audit trail.
64    #[serde(default)]
65    pub rejections: Vec<RejectRecord>,
66    #[serde(default)]
67    pub phase: ProposalPhase,
68}
69
70pub struct ProposalMode {
71    evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
72}
73
74impl ProposalMode {
75    /// Construct the mode with an injected governance policy evaluator.
76    pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
77        Self { evaluator }
78    }
79
80    fn encode_state(state: &ProposalState) -> Vec<u8> {
81        serde_json::to_vec(state).expect("ProposalState is always serializable")
82    }
83
84    fn decode_state(data: &[u8]) -> Result<ProposalState, MacpError> {
85        serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState)
86    }
87
88    fn live_proposal<'a>(
89        state: &'a ProposalState,
90        proposal_id: &str,
91    ) -> Option<&'a ProposalRecord> {
92        state
93            .proposals
94            .get(proposal_id)
95            .filter(|record| record.disposition == ProposalDisposition::Live)
96    }
97
98    fn refresh_phase(session: &Session, state: &mut ProposalState) {
99        // RFC-MACP-0012: acceptance.criterion controls convergence check
100        let criterion = session
101            .policy_definition
102            .as_ref()
103            .map(|p| {
104                serde_json::from_value::<macp_core::policy::rules::ProposalPolicyRules>(
105                    p.rules.clone(),
106                )
107                .unwrap_or_default()
108                .acceptance
109                .criterion
110            })
111            .unwrap_or_else(|| "all_parties".to_string());
112
113        state.phase = if !state.terminal_rejections.is_empty() {
114            ProposalPhase::TerminalRejected
115        } else if state
116            .proposals
117            .values()
118            .filter(|proposal| proposal.disposition == ProposalDisposition::Live)
119            .any(|proposal| {
120                Self::check_acceptance_criterion(
121                    &criterion,
122                    session,
123                    state,
124                    &proposal.proposal_id,
125                    &proposal.proposer,
126                )
127            })
128        {
129            ProposalPhase::Converged
130        } else {
131            ProposalPhase::Negotiating
132        };
133    }
134
135    fn check_acceptance_criterion(
136        criterion: &str,
137        session: &Session,
138        state: &ProposalState,
139        proposal_id: &str,
140        proposer: &str,
141    ) -> bool {
142        match criterion {
143            "counterparty" => {
144                // All participants except the proposer must accept
145                session
146                    .participants
147                    .iter()
148                    .filter(|p| p.as_str() != proposer)
149                    .all(|p| state.accepts.get(p).map(String::as_str) == Some(proposal_id))
150            }
151            "initiator" => {
152                // Only the session initiator must accept
153                state
154                    .accepts
155                    .get(&session.initiator_sender)
156                    .map(String::as_str)
157                    == Some(proposal_id)
158            }
159            _ => {
160                // "all_parties" (default)
161                participants_all_accept(&session.participants, &state.accepts, proposal_id)
162            }
163        }
164    }
165
166    fn commitment_ready(state: &ProposalState) -> bool {
167        matches!(
168            state.phase,
169            ProposalPhase::Converged | ProposalPhase::TerminalRejected
170        )
171    }
172
173    fn ensure_mutable(state: &ProposalState) -> Result<(), MacpError> {
174        if state.phase == ProposalPhase::Committed {
175            Err(MacpError::SessionNotOpen)
176        } else {
177            Ok(())
178        }
179    }
180}
181
182impl Mode for ProposalMode {
183    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
184        match env.message_type.as_str() {
185            "Commitment" => check_commitment_authority(session, &env.sender),
186            _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
187            _ => Err(MacpError::Forbidden),
188        }
189    }
190
191    fn on_session_start(
192        &self,
193        session: &Session,
194        _env: &Envelope,
195    ) -> Result<ModeResponse, MacpError> {
196        if session.participants.is_empty() {
197            return Err(MacpError::InvalidPayload);
198        }
199        Ok(ModeResponse::PersistState(Self::encode_state(
200            &ProposalState::default(),
201        )))
202    }
203
204    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
205        let mut state = if session.mode_state.is_empty() {
206            ProposalState::default()
207        } else {
208            Self::decode_state(&session.mode_state)?
209        };
210        Self::ensure_mutable(&state)?;
211
212        match env.message_type.as_str() {
213            "Proposal" => {
214                let payload = ProposalPayload::decode(&*env.payload)
215                    .map_err(|_| MacpError::InvalidPayload)?;
216                if payload.proposal_id.trim().is_empty()
217                    || state.proposals.contains_key(&payload.proposal_id)
218                {
219                    return Err(MacpError::InvalidPayload);
220                }
221                state.proposals.insert(
222                    payload.proposal_id.clone(),
223                    ProposalRecord {
224                        proposal_id: payload.proposal_id,
225                        title: payload.title,
226                        summary: payload.summary,
227                        details: payload.details,
228                        tags: payload.tags,
229                        proposer: env.sender.clone(),
230                        supersedes_proposal_id: None,
231                        disposition: ProposalDisposition::Live,
232                    },
233                );
234                Self::refresh_phase(session, &mut state);
235                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
236            }
237            "CounterProposal" => {
238                let payload = CounterProposalPayload::decode(&*env.payload)
239                    .map_err(|_| MacpError::InvalidPayload)?;
240                if payload.proposal_id.trim().is_empty()
241                    || payload.supersedes_proposal_id.trim().is_empty()
242                    || state.proposals.contains_key(&payload.proposal_id)
243                    || !state
244                        .proposals
245                        .contains_key(&payload.supersedes_proposal_id)
246                {
247                    return Err(MacpError::InvalidPayload);
248                }
249                // RFC-MACP-0012: enforce max_rounds at submission time to prevent
250                // unbounded state growth. The evaluator also checks at commitment.
251                if let Some(ref policy) = session.policy_definition {
252                    let rules = serde_json::from_value::<
253                        macp_core::policy::rules::ProposalPolicyRules,
254                    >(policy.rules.clone())
255                    .unwrap_or_default();
256                    if rules.counter_proposal.max_rounds > 0 {
257                        let counter_count = state
258                            .proposals
259                            .values()
260                            .filter(|p| p.supersedes_proposal_id.is_some())
261                            .count();
262                        if counter_count >= rules.counter_proposal.max_rounds {
263                            return Err(MacpError::InvalidPayload);
264                        }
265                    }
266                }
267                state.proposals.insert(
268                    payload.proposal_id.clone(),
269                    ProposalRecord {
270                        proposal_id: payload.proposal_id,
271                        title: payload.title,
272                        summary: payload.summary,
273                        details: payload.details,
274                        tags: Vec::new(),
275                        proposer: env.sender.clone(),
276                        supersedes_proposal_id: Some(payload.supersedes_proposal_id),
277                        disposition: ProposalDisposition::Live,
278                    },
279                );
280                Self::refresh_phase(session, &mut state);
281                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
282            }
283            "Accept" => {
284                let payload =
285                    AcceptPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
286                if Self::live_proposal(&state, &payload.proposal_id).is_none() {
287                    return Err(MacpError::InvalidPayload);
288                }
289                state
290                    .accepts
291                    .insert(env.sender.clone(), payload.proposal_id);
292                Self::refresh_phase(session, &mut state);
293                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
294            }
295            "Reject" => {
296                let payload =
297                    RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
298                // Reject only applies to live proposals (consistent with Accept validation)
299                if Self::live_proposal(&state, &payload.proposal_id).is_none() {
300                    return Err(MacpError::InvalidPayload);
301                }
302                // RFC-MACP-0012: terminal_on_any_reject overrides per-message terminal flag
303                let is_terminal = payload.terminal
304                    || session.policy_definition.as_ref().is_some_and(|p| {
305                        serde_json::from_value::<macp_core::policy::rules::ProposalPolicyRules>(
306                            p.rules.clone(),
307                        )
308                        .unwrap_or_default()
309                        .rejection
310                        .terminal_on_any_reject
311                    });
312                // Always record the rejection for audit trail
313                state.rejections.push(RejectRecord {
314                    proposal_id: payload.proposal_id.clone(),
315                    sender: env.sender.clone(),
316                    reason: payload.reason.clone(),
317                    terminal: is_terminal,
318                });
319                if is_terminal {
320                    state.terminal_rejections.push(TerminalRejectRecord {
321                        proposal_id: payload.proposal_id,
322                        sender: env.sender.clone(),
323                        reason: payload.reason,
324                    });
325                }
326                Self::refresh_phase(session, &mut state);
327                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
328            }
329            "Withdraw" => {
330                let payload = WithdrawPayload::decode(&*env.payload)
331                    .map_err(|_| MacpError::InvalidPayload)?;
332                if payload.proposal_id.trim().is_empty() {
333                    return Err(MacpError::InvalidPayload);
334                }
335                let record = state
336                    .proposals
337                    .get_mut(&payload.proposal_id)
338                    .ok_or(MacpError::InvalidPayload)?;
339                if record.proposer != env.sender {
340                    return Err(MacpError::Forbidden);
341                }
342                if record.disposition == ProposalDisposition::Withdrawn {
343                    return Err(MacpError::InvalidPayload);
344                }
345                record.disposition = ProposalDisposition::Withdrawn;
346                state.accepts.retain(|_, pid| pid != &payload.proposal_id);
347                state
348                    .terminal_rejections
349                    .retain(|r| r.proposal_id != payload.proposal_id);
350                Self::refresh_phase(session, &mut state);
351                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
352            }
353            "Commitment" => {
354                validate_commitment_payload_for_session(session, &env.payload)?;
355                Self::refresh_phase(session, &mut state);
356                if !Self::commitment_ready(&state) {
357                    return Err(MacpError::InvalidPayload);
358                }
359                // Evaluate governance policy if one is bound to the session.
360                if let Some(ref policy) = session.policy_definition {
361                    let counter_count = state
362                        .proposals
363                        .values()
364                        .filter(|p| p.supersedes_proposal_id.is_some())
365                        .count();
366                    let decision = self
367                        .evaluator
368                        .evaluate_proposal_commitment(policy, counter_count);
369                    if let macp_core::policy::PolicyDecision::Deny { reasons } = decision {
370                        tracing::warn!(
371                            session_id = %session.session_id,
372                            policy_id = %policy.policy_id,
373                            reasons = ?reasons,
374                            "policy denied commitment"
375                        );
376                        return Err(MacpError::PolicyDenied { reasons });
377                    }
378                }
379                state.phase = ProposalPhase::Committed;
380                Ok(ModeResponse::PersistAndResolve {
381                    state: Self::encode_state(&state),
382                    resolution: env.payload.clone(),
383                })
384            }
385            _ => Err(MacpError::InvalidPayload),
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use macp_core::session::{Session, SessionState};
394    use macp_pb::pb::CommitmentPayload;
395    use std::collections::HashSet;
396
397    fn base_session() -> Session {
398        Session {
399            session_id: "s1".into(),
400            state: SessionState::Open,
401            ttl_expiry: i64::MAX,
402            ttl_ms: 60_000,
403            started_at_unix_ms: 0,
404            resolution: None,
405            mode: "macp.mode.proposal.v1".into(),
406            mode_state: vec![],
407            participants: vec!["agent://buyer".into(), "agent://seller".into()],
408            seen_message_ids: HashSet::new(),
409            intent: String::new(),
410            mode_version: "1.0.0".into(),
411            configuration_version: "cfg-1".into(),
412            policy_version: "policy-1".into(),
413            context_id: String::new(),
414            extensions: std::collections::HashMap::new(),
415            roots: vec![],
416            initiator_sender: "agent://buyer".into(),
417            participant_message_counts: std::collections::HashMap::new(),
418            participant_last_seen: std::collections::HashMap::new(),
419            policy_definition: None,
420            suspended_at_ms: None,
421            accumulated_suspended_ms: 0,
422        }
423    }
424
425    fn decode(session: &Session) -> ProposalState {
426        serde_json::from_slice(&session.mode_state).unwrap()
427    }
428
429    fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
430        Envelope {
431            macp_version: "1.0".into(),
432            mode: "macp.mode.proposal.v1".into(),
433            message_type: message_type.into(),
434            message_id: format!("{}-{}", sender, message_type),
435            session_id: "s1".into(),
436            sender: sender.into(),
437            timestamp_unix_ms: 0,
438            payload,
439        }
440    }
441
442    fn commitment(session: &Session, action: &str) -> Vec<u8> {
443        let outcome_positive = !action.contains("rejected")
444            && !action.contains("failed")
445            && !action.contains("declined");
446        CommitmentPayload {
447            commitment_id: "c1".into(),
448            action: action.into(),
449            authority_scope: "commercial".into(),
450            reason: "bound".into(),
451            mode_version: session.mode_version.clone(),
452            policy_version: session.policy_version.clone(),
453            configuration_version: session.configuration_version.clone(),
454            outcome_positive,
455            supersedes: None,
456        }
457        .encode_to_vec()
458    }
459
460    fn apply(session: &mut Session, result: ModeResponse) {
461        match result {
462            ModeResponse::PersistState(data) => session.mode_state = data,
463            ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
464            _ => {}
465        }
466    }
467
468    fn make_proposal(id: &str) -> Vec<u8> {
469        ProposalPayload {
470            proposal_id: id.into(),
471            title: format!("offer-{id}"),
472            summary: "summary".into(),
473            details: vec![],
474            tags: vec![],
475        }
476        .encode_to_vec()
477    }
478
479    fn make_accept(id: &str) -> Vec<u8> {
480        AcceptPayload {
481            proposal_id: id.into(),
482            reason: String::new(),
483        }
484        .encode_to_vec()
485    }
486
487    fn make_reject(id: &str, terminal: bool) -> Vec<u8> {
488        RejectPayload {
489            proposal_id: id.into(),
490            terminal,
491            reason: "no".into(),
492        }
493        .encode_to_vec()
494    }
495
496    fn make_withdraw(id: &str) -> Vec<u8> {
497        WithdrawPayload {
498            proposal_id: id.into(),
499            reason: "changed mind".into(),
500        }
501        .encode_to_vec()
502    }
503
504    #[test]
505    fn session_start_requires_participants() {
506        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
507        let mut session = base_session();
508        session.participants.clear();
509        assert_eq!(
510            mode.on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
511                .unwrap_err()
512                .to_string(),
513            "InvalidPayload"
514        );
515    }
516
517    #[test]
518    fn commitment_requires_acceptance_convergence() {
519        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
520        let mut session = base_session();
521        let resp = mode
522            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
523            .unwrap();
524        apply(&mut session, resp);
525        let resp = mode
526            .on_message(
527                &session,
528                &env("agent://seller", "Proposal", make_proposal("p1")),
529            )
530            .unwrap();
531        apply(&mut session, resp);
532
533        assert_eq!(
534            mode.on_message(
535                &session,
536                &env(
537                    "agent://buyer",
538                    "Commitment",
539                    commitment(&session, "proposal.accepted"),
540                ),
541            )
542            .unwrap_err()
543            .to_string(),
544            "InvalidPayload"
545        );
546
547        let resp = mode
548            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
549            .unwrap();
550        apply(&mut session, resp);
551        let resp = mode
552            .on_message(
553                &session,
554                &env("agent://seller", "Accept", make_accept("p1")),
555            )
556            .unwrap();
557        apply(&mut session, resp);
558        mode.on_message(
559            &session,
560            &env(
561                "agent://buyer",
562                "Commitment",
563                commitment(&session, "proposal.accepted"),
564            ),
565        )
566        .unwrap();
567    }
568
569    #[test]
570    fn terminal_rejection_allows_negative_commitment() {
571        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
572        let mut session = base_session();
573        let resp = mode
574            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
575            .unwrap();
576        apply(&mut session, resp);
577        let resp = mode
578            .on_message(
579                &session,
580                &env("agent://seller", "Proposal", make_proposal("p1")),
581            )
582            .unwrap();
583        apply(&mut session, resp);
584        let resp = mode
585            .on_message(
586                &session,
587                &env("agent://buyer", "Reject", make_reject("p1", true)),
588            )
589            .unwrap();
590        apply(&mut session, resp);
591        mode.on_message(
592            &session,
593            &env(
594                "agent://buyer",
595                "Commitment",
596                commitment(&session, "proposal.rejected"),
597            ),
598        )
599        .unwrap();
600    }
601
602    #[test]
603    fn withdraw_clears_terminal_rejections() {
604        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
605        let mut session = base_session();
606        let resp = mode
607            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
608            .unwrap();
609        apply(&mut session, resp);
610        // Seller proposes p1
611        let resp = mode
612            .on_message(
613                &session,
614                &env("agent://seller", "Proposal", make_proposal("p1")),
615            )
616            .unwrap();
617        apply(&mut session, resp);
618        // Buyer terminally rejects p1
619        let resp = mode
620            .on_message(
621                &session,
622                &env("agent://buyer", "Reject", make_reject("p1", true)),
623            )
624            .unwrap();
625        apply(&mut session, resp);
626        // Seller withdraws p1 — terminal rejection should be cleared
627        let resp = mode
628            .on_message(
629                &session,
630                &env("agent://seller", "Withdraw", make_withdraw("p1")),
631            )
632            .unwrap();
633        apply(&mut session, resp);
634        // Seller proposes p2
635        let resp = mode
636            .on_message(
637                &session,
638                &env("agent://seller", "Proposal", make_proposal("p2")),
639            )
640            .unwrap();
641        apply(&mut session, resp);
642        // Commitment should NOT be ready (no acceptance convergence, no terminal rejection)
643        let err = mode
644            .on_message(
645                &session,
646                &env(
647                    "agent://buyer",
648                    "Commitment",
649                    commitment(&session, "proposal.rejected"),
650                ),
651            )
652            .unwrap_err();
653        assert_eq!(err.to_string(), "InvalidPayload");
654    }
655
656    fn make_counter_proposal(id: &str, supersedes: &str) -> Vec<u8> {
657        CounterProposalPayload {
658            proposal_id: id.into(),
659            supersedes_proposal_id: supersedes.into(),
660            title: format!("counter-{id}"),
661            summary: "counter".into(),
662            details: vec![],
663        }
664        .encode_to_vec()
665    }
666
667    #[test]
668    fn empty_proposal_id_rejected() {
669        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
670        let mut session = base_session();
671        let resp = mode
672            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
673            .unwrap();
674        apply(&mut session, resp);
675        let empty = ProposalPayload {
676            proposal_id: "".into(),
677            title: "title".into(),
678            summary: "summary".into(),
679            details: vec![],
680            tags: vec![],
681        }
682        .encode_to_vec();
683        assert_eq!(
684            mode.on_message(&session, &env("agent://seller", "Proposal", empty))
685                .unwrap_err()
686                .to_string(),
687            "InvalidPayload"
688        );
689    }
690
691    #[test]
692    fn counterproposal_requires_valid_supersedes() {
693        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
694        let mut session = base_session();
695        let resp = mode
696            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
697            .unwrap();
698        apply(&mut session, resp);
699        let resp = mode
700            .on_message(
701                &session,
702                &env("agent://seller", "Proposal", make_proposal("p1")),
703            )
704            .unwrap();
705        apply(&mut session, resp);
706        // Supersedes non-existent proposal
707        let bad_counter = make_counter_proposal("p2", "nonexistent");
708        assert_eq!(
709            mode.on_message(
710                &session,
711                &env("agent://buyer", "CounterProposal", bad_counter)
712            )
713            .unwrap_err()
714            .to_string(),
715            "InvalidPayload"
716        );
717    }
718
719    #[test]
720    fn counterproposal_chain_works() {
721        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
722        let mut session = base_session();
723        let resp = mode
724            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
725            .unwrap();
726        apply(&mut session, resp);
727        let resp = mode
728            .on_message(
729                &session,
730                &env("agent://seller", "Proposal", make_proposal("p1")),
731            )
732            .unwrap();
733        apply(&mut session, resp);
734        let resp = mode
735            .on_message(
736                &session,
737                &env(
738                    "agent://buyer",
739                    "CounterProposal",
740                    make_counter_proposal("p2", "p1"),
741                ),
742            )
743            .unwrap();
744        apply(&mut session, resp);
745        // Chain: p3 supersedes p2
746        mode.on_message(
747            &session,
748            &env(
749                "agent://seller",
750                "CounterProposal",
751                make_counter_proposal("p3", "p2"),
752            ),
753        )
754        .unwrap();
755    }
756
757    #[test]
758    fn non_terminal_reject_does_not_enable_commitment() {
759        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
760        let mut session = base_session();
761        let resp = mode
762            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
763            .unwrap();
764        apply(&mut session, resp);
765        let resp = mode
766            .on_message(
767                &session,
768                &env("agent://seller", "Proposal", make_proposal("p1")),
769            )
770            .unwrap();
771        apply(&mut session, resp);
772        // Non-terminal reject
773        let resp = mode
774            .on_message(
775                &session,
776                &env("agent://buyer", "Reject", make_reject("p1", false)),
777            )
778            .unwrap();
779        apply(&mut session, resp);
780        // Verify the rejection is still recorded in state for audit
781        let state = decode(&session);
782        assert_eq!(state.rejections.len(), 1);
783        assert_eq!(state.rejections[0].proposal_id, "p1");
784        assert!(!state.rejections[0].terminal);
785        assert!(state.terminal_rejections.is_empty());
786        // But commitment should still be blocked
787        assert_eq!(
788            mode.on_message(
789                &session,
790                &env(
791                    "agent://buyer",
792                    "Commitment",
793                    commitment(&session, "proposal.rejected"),
794                ),
795            )
796            .unwrap_err()
797            .to_string(),
798            "InvalidPayload"
799        );
800    }
801
802    #[test]
803    fn non_participant_cannot_propose() {
804        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
805        let session = base_session();
806        let err = mode
807            .authorize_sender(
808                &session,
809                &env("agent://outsider", "Proposal", make_proposal("p1")),
810            )
811            .unwrap_err();
812        assert_eq!(err.to_string(), "Forbidden");
813    }
814
815    #[test]
816    fn commitment_version_mismatch_rejected() {
817        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
818        let mut session = base_session();
819        let resp = mode
820            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
821            .unwrap();
822        apply(&mut session, resp);
823        let resp = mode
824            .on_message(
825                &session,
826                &env("agent://seller", "Proposal", make_proposal("p1")),
827            )
828            .unwrap();
829        apply(&mut session, resp);
830        let resp = mode
831            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
832            .unwrap();
833        apply(&mut session, resp);
834        let resp = mode
835            .on_message(
836                &session,
837                &env("agent://seller", "Accept", make_accept("p1")),
838            )
839            .unwrap();
840        apply(&mut session, resp);
841        let bad = CommitmentPayload {
842            commitment_id: "c1".into(),
843            action: "proposal.accepted".into(),
844            authority_scope: "commercial".into(),
845            reason: "bound".into(),
846            mode_version: "wrong".into(),
847            policy_version: session.policy_version.clone(),
848            configuration_version: session.configuration_version.clone(),
849            outcome_positive: true,
850            supersedes: None,
851        }
852        .encode_to_vec();
853        assert_eq!(
854            mode.on_message(&session, &env("agent://buyer", "Commitment", bad))
855                .unwrap_err()
856                .to_string(),
857            "InvalidPayload"
858        );
859    }
860
861    #[test]
862    fn accept_on_withdrawn_proposal_rejected() {
863        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
864        let mut session = base_session();
865        let resp = mode
866            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
867            .unwrap();
868        apply(&mut session, resp);
869        let resp = mode
870            .on_message(
871                &session,
872                &env("agent://seller", "Proposal", make_proposal("p1")),
873            )
874            .unwrap();
875        apply(&mut session, resp);
876        let resp = mode
877            .on_message(
878                &session,
879                &env("agent://seller", "Withdraw", make_withdraw("p1")),
880            )
881            .unwrap();
882        apply(&mut session, resp);
883        assert_eq!(
884            mode.on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
885                .unwrap_err()
886                .to_string(),
887            "InvalidPayload"
888        );
889    }
890
891    #[test]
892    fn commitment_from_non_initiator_rejected() {
893        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
894        let session = base_session();
895        let err = mode
896            .authorize_sender(
897                &session,
898                &env(
899                    "agent://seller",
900                    "Commitment",
901                    commitment(&session, "proposal.accepted"),
902                ),
903            )
904            .unwrap_err();
905        assert_eq!(err.to_string(), "Forbidden");
906    }
907
908    #[test]
909    fn terminal_rejection_on_different_proposal_survives_withdraw() {
910        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
911        let mut session = base_session();
912        let resp = mode
913            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
914            .unwrap();
915        apply(&mut session, resp);
916        // Seller proposes p1 and p2
917        let resp = mode
918            .on_message(
919                &session,
920                &env("agent://seller", "Proposal", make_proposal("p1")),
921            )
922            .unwrap();
923        apply(&mut session, resp);
924        let resp = mode
925            .on_message(
926                &session,
927                &env("agent://seller", "Proposal", make_proposal("p2")),
928            )
929            .unwrap();
930        apply(&mut session, resp);
931        // Buyer terminally rejects p2
932        let resp = mode
933            .on_message(
934                &session,
935                &env("agent://buyer", "Reject", make_reject("p2", true)),
936            )
937            .unwrap();
938        apply(&mut session, resp);
939        // Seller withdraws p1 — p2's terminal rejection survives
940        let resp = mode
941            .on_message(
942                &session,
943                &env("agent://seller", "Withdraw", make_withdraw("p1")),
944            )
945            .unwrap();
946        apply(&mut session, resp);
947        // Commitment should be ready because p2 still has a terminal rejection
948        mode.on_message(
949            &session,
950            &env(
951                "agent://buyer",
952                "Commitment",
953                commitment(&session, "proposal.rejected"),
954            ),
955        )
956        .unwrap();
957    }
958
959    #[test]
960    fn phase_becomes_converged_when_all_participants_accept_same_live_proposal() {
961        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
962        let mut session = base_session();
963        let resp = mode
964            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
965            .unwrap();
966        apply(&mut session, resp);
967        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
968
969        let resp = mode
970            .on_message(
971                &session,
972                &env("agent://seller", "Proposal", make_proposal("p1")),
973            )
974            .unwrap();
975        apply(&mut session, resp);
976        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
977
978        let resp = mode
979            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
980            .unwrap();
981        apply(&mut session, resp);
982        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
983
984        let resp = mode
985            .on_message(
986                &session,
987                &env("agent://seller", "Accept", make_accept("p1")),
988            )
989            .unwrap();
990        apply(&mut session, resp);
991        assert_eq!(decode(&session).phase, ProposalPhase::Converged);
992    }
993
994    #[test]
995    fn terminal_reject_sets_terminal_rejected_phase() {
996        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
997        let mut session = base_session();
998        let resp = mode
999            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1000            .unwrap();
1001        apply(&mut session, resp);
1002        let resp = mode
1003            .on_message(
1004                &session,
1005                &env("agent://seller", "Proposal", make_proposal("p1")),
1006            )
1007            .unwrap();
1008        apply(&mut session, resp);
1009        let resp = mode
1010            .on_message(
1011                &session,
1012                &env("agent://buyer", "Reject", make_reject("p1", true)),
1013            )
1014            .unwrap();
1015        apply(&mut session, resp);
1016        assert_eq!(decode(&session).phase, ProposalPhase::TerminalRejected);
1017    }
1018
1019    #[test]
1020    fn malformed_counterproposal_payload_rejected() {
1021        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1022        let mut session = base_session();
1023        let resp = mode
1024            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1025            .unwrap();
1026        apply(&mut session, resp);
1027        assert_eq!(
1028            mode.on_message(
1029                &session,
1030                &env("agent://seller", "CounterProposal", vec![0xff, 0x00])
1031            )
1032            .unwrap_err()
1033            .to_string(),
1034            "InvalidPayload"
1035        );
1036    }
1037
1038    #[test]
1039    fn policy_blocks_counter_proposal_at_submission_when_limit_exceeded() {
1040        // RFC-MACP-0012: max_rounds is enforced both at submission and commitment time.
1041        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1042        let mut session = base_session();
1043        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1044            policy_id: "test-limited".into(),
1045            mode: "macp.mode.proposal.v1".into(),
1046            description: "limited".into(),
1047            rules: serde_json::json!({
1048                "counter_proposal": { "max_rounds": 1 }
1049            }),
1050            schema_version: 1,
1051        });
1052        let resp = mode
1053            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1054            .unwrap();
1055        apply(&mut session, resp);
1056        // Seller proposes p1
1057        let resp = mode
1058            .on_message(
1059                &session,
1060                &env("agent://seller", "Proposal", make_proposal("p1")),
1061            )
1062            .unwrap();
1063        apply(&mut session, resp);
1064        // Buyer counter-proposes p2 (supersedes p1) -- 1st counter-proposal: allowed
1065        let resp = mode
1066            .on_message(
1067                &session,
1068                &env(
1069                    "agent://buyer",
1070                    "CounterProposal",
1071                    make_counter_proposal("p2", "p1"),
1072                ),
1073            )
1074            .unwrap();
1075        apply(&mut session, resp);
1076        // Seller counter-proposes p3 (supersedes p2) -- 2nd counter-proposal:
1077        // REJECTED at submission time (max_rounds=1, already 1 counter-proposal)
1078        let err = mode
1079            .on_message(
1080                &session,
1081                &env(
1082                    "agent://seller",
1083                    "CounterProposal",
1084                    make_counter_proposal("p3", "p2"),
1085                ),
1086            )
1087            .unwrap_err();
1088        assert_eq!(err.to_string(), "InvalidPayload");
1089    }
1090
1091    // --- CounterProposal does NOT retire original ---
1092
1093    #[test]
1094    fn counter_proposal_does_not_retire_original() {
1095        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1096        let mut session = base_session();
1097        let resp = mode
1098            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1099            .unwrap();
1100        apply(&mut session, resp);
1101        // Seller proposes p1
1102        let resp = mode
1103            .on_message(
1104                &session,
1105                &env("agent://seller", "Proposal", make_proposal("p1")),
1106            )
1107            .unwrap();
1108        apply(&mut session, resp);
1109        // Buyer counter-proposes p2, superseding p1
1110        let resp = mode
1111            .on_message(
1112                &session,
1113                &env(
1114                    "agent://buyer",
1115                    "CounterProposal",
1116                    make_counter_proposal("p2", "p1"),
1117                ),
1118            )
1119            .unwrap();
1120        apply(&mut session, resp);
1121        // Verify both proposals are still live in the mode state
1122        let state = decode(&session);
1123        assert_eq!(state.proposals.len(), 2);
1124        assert_eq!(state.proposals["p1"].disposition, ProposalDisposition::Live);
1125        assert_eq!(state.proposals["p2"].disposition, ProposalDisposition::Live);
1126        assert_eq!(
1127            state.proposals["p2"].supersedes_proposal_id,
1128            Some("p1".into())
1129        );
1130    }
1131
1132    #[test]
1133    fn reject_withdrawn_proposal_fails() {
1134        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1135        let mut session = base_session();
1136        let resp = mode
1137            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1138            .unwrap();
1139        apply(&mut session, resp);
1140        let resp = mode
1141            .on_message(
1142                &session,
1143                &env("agent://buyer", "Proposal", make_proposal("p1")),
1144            )
1145            .unwrap();
1146        apply(&mut session, resp);
1147        // Withdraw p1
1148        let resp = mode
1149            .on_message(
1150                &session,
1151                &env("agent://buyer", "Withdraw", make_withdraw("p1")),
1152            )
1153            .unwrap();
1154        apply(&mut session, resp);
1155        // Reject on withdrawn proposal should fail
1156        assert_eq!(
1157            mode.on_message(
1158                &session,
1159                &env("agent://seller", "Reject", make_reject("p1", false))
1160            )
1161            .unwrap_err()
1162            .to_string(),
1163            "InvalidPayload"
1164        );
1165    }
1166
1167    #[test]
1168    fn withdraw_empty_proposal_id_rejected() {
1169        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1170        let mut session = base_session();
1171        let resp = mode
1172            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1173            .unwrap();
1174        apply(&mut session, resp);
1175        let empty_withdraw = WithdrawPayload {
1176            proposal_id: String::new(),
1177            reason: "empty".into(),
1178        }
1179        .encode_to_vec();
1180        assert_eq!(
1181            mode.on_message(&session, &env("agent://buyer", "Withdraw", empty_withdraw))
1182                .unwrap_err()
1183                .to_string(),
1184            "InvalidPayload"
1185        );
1186    }
1187}