Skip to main content

macp_modes/mode/
proposal.rs

1use crate::mode::util::{
2    check_commitment_authority, enforce_commitment_policy, is_declared_participant,
3    participants_all_accept, 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        crate::mode::util::encode_mode_state(state)
82    }
83
84    fn decode_state(data: &[u8]) -> Result<ProposalState, MacpError> {
85        crate::mode::util::decode_mode_state(data)
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                let commitment = 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                // Governance policy gate (shared): fail closed, only
360                // an explicit Allow proceeds.
361                let counter_count = state
362                    .proposals
363                    .values()
364                    .filter(|p| p.supersedes_proposal_id.is_some())
365                    .count();
366                enforce_commitment_policy(
367                    session,
368                    macp_core::policy::CommitmentMode::Proposal {
369                        counter_proposal_count: counter_count,
370                    },
371                    commitment.outcome_positive,
372                    &*self.evaluator,
373                )?;
374                state.phase = ProposalPhase::Committed;
375                Ok(ModeResponse::PersistAndResolve {
376                    state: Self::encode_state(&state),
377                    resolution: env.payload.clone(),
378                })
379            }
380            _ => Err(MacpError::InvalidPayload),
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use macp_core::session::Session;
389    use macp_pb::pb::CommitmentPayload;
390
391    fn base_session() -> Session {
392        Session::builder("s1", "macp.mode.proposal.v1", "agent://buyer")
393            .ttl_ms(60_000)
394            .participants(vec!["agent://buyer".into(), "agent://seller".into()])
395            .mode_version("1.0.0")
396            .configuration_version("cfg-1")
397            .policy_version("policy-1")
398            .build()
399    }
400
401    fn decode(session: &Session) -> ProposalState {
402        serde_json::from_slice(&session.mode_state).unwrap()
403    }
404
405    fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
406        Envelope {
407            macp_version: "1.0".into(),
408            mode: "macp.mode.proposal.v1".into(),
409            message_type: message_type.into(),
410            message_id: format!("{}-{}", sender, message_type),
411            session_id: "s1".into(),
412            sender: sender.into(),
413            timestamp_unix_ms: 0,
414            payload,
415        }
416    }
417
418    fn commitment(session: &Session, action: &str) -> Vec<u8> {
419        let outcome_positive = !action.contains("rejected")
420            && !action.contains("failed")
421            && !action.contains("declined");
422        CommitmentPayload {
423            commitment_id: "c1".into(),
424            action: action.into(),
425            authority_scope: "commercial".into(),
426            reason: "bound".into(),
427            mode_version: session.mode_version.clone(),
428            policy_version: session.policy_version.clone(),
429            configuration_version: session.configuration_version.clone(),
430            outcome_positive,
431            supersedes: None,
432        }
433        .encode_to_vec()
434    }
435
436    fn apply(session: &mut Session, result: ModeResponse) {
437        match result {
438            ModeResponse::PersistState(data) => session.mode_state = data,
439            ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
440            _ => {}
441        }
442    }
443
444    fn make_proposal(id: &str) -> Vec<u8> {
445        ProposalPayload {
446            proposal_id: id.into(),
447            title: format!("offer-{id}"),
448            summary: "summary".into(),
449            details: vec![],
450            tags: vec![],
451        }
452        .encode_to_vec()
453    }
454
455    fn make_accept(id: &str) -> Vec<u8> {
456        AcceptPayload {
457            proposal_id: id.into(),
458            reason: String::new(),
459        }
460        .encode_to_vec()
461    }
462
463    fn make_reject(id: &str, terminal: bool) -> Vec<u8> {
464        RejectPayload {
465            proposal_id: id.into(),
466            terminal,
467            reason: "no".into(),
468        }
469        .encode_to_vec()
470    }
471
472    fn make_withdraw(id: &str) -> Vec<u8> {
473        WithdrawPayload {
474            proposal_id: id.into(),
475            reason: "changed mind".into(),
476        }
477        .encode_to_vec()
478    }
479
480    #[test]
481    fn session_start_requires_participants() {
482        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
483        let mut session = base_session();
484        session.participants.clear();
485        assert_eq!(
486            mode.on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
487                .unwrap_err()
488                .to_string(),
489            "InvalidPayload"
490        );
491    }
492
493    #[test]
494    fn commitment_requires_acceptance_convergence() {
495        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
496        let mut session = base_session();
497        let resp = mode
498            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
499            .unwrap();
500        apply(&mut session, resp);
501        let resp = mode
502            .on_message(
503                &session,
504                &env("agent://seller", "Proposal", make_proposal("p1")),
505            )
506            .unwrap();
507        apply(&mut session, resp);
508
509        assert_eq!(
510            mode.on_message(
511                &session,
512                &env(
513                    "agent://buyer",
514                    "Commitment",
515                    commitment(&session, "proposal.accepted"),
516                ),
517            )
518            .unwrap_err()
519            .to_string(),
520            "InvalidPayload"
521        );
522
523        let resp = mode
524            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
525            .unwrap();
526        apply(&mut session, resp);
527        let resp = mode
528            .on_message(
529                &session,
530                &env("agent://seller", "Accept", make_accept("p1")),
531            )
532            .unwrap();
533        apply(&mut session, resp);
534        mode.on_message(
535            &session,
536            &env(
537                "agent://buyer",
538                "Commitment",
539                commitment(&session, "proposal.accepted"),
540            ),
541        )
542        .unwrap();
543    }
544
545    #[test]
546    fn terminal_rejection_allows_negative_commitment() {
547        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
548        let mut session = base_session();
549        let resp = mode
550            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
551            .unwrap();
552        apply(&mut session, resp);
553        let resp = mode
554            .on_message(
555                &session,
556                &env("agent://seller", "Proposal", make_proposal("p1")),
557            )
558            .unwrap();
559        apply(&mut session, resp);
560        let resp = mode
561            .on_message(
562                &session,
563                &env("agent://buyer", "Reject", make_reject("p1", true)),
564            )
565            .unwrap();
566        apply(&mut session, resp);
567        mode.on_message(
568            &session,
569            &env(
570                "agent://buyer",
571                "Commitment",
572                commitment(&session, "proposal.rejected"),
573            ),
574        )
575        .unwrap();
576    }
577
578    #[test]
579    fn withdraw_clears_terminal_rejections() {
580        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
581        let mut session = base_session();
582        let resp = mode
583            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
584            .unwrap();
585        apply(&mut session, resp);
586        // Seller proposes p1
587        let resp = mode
588            .on_message(
589                &session,
590                &env("agent://seller", "Proposal", make_proposal("p1")),
591            )
592            .unwrap();
593        apply(&mut session, resp);
594        // Buyer terminally rejects p1
595        let resp = mode
596            .on_message(
597                &session,
598                &env("agent://buyer", "Reject", make_reject("p1", true)),
599            )
600            .unwrap();
601        apply(&mut session, resp);
602        // Seller withdraws p1 — terminal rejection should be cleared
603        let resp = mode
604            .on_message(
605                &session,
606                &env("agent://seller", "Withdraw", make_withdraw("p1")),
607            )
608            .unwrap();
609        apply(&mut session, resp);
610        // Seller proposes p2
611        let resp = mode
612            .on_message(
613                &session,
614                &env("agent://seller", "Proposal", make_proposal("p2")),
615            )
616            .unwrap();
617        apply(&mut session, resp);
618        // Commitment should NOT be ready (no acceptance convergence, no terminal rejection)
619        let err = mode
620            .on_message(
621                &session,
622                &env(
623                    "agent://buyer",
624                    "Commitment",
625                    commitment(&session, "proposal.rejected"),
626                ),
627            )
628            .unwrap_err();
629        assert_eq!(err.to_string(), "InvalidPayload");
630    }
631
632    fn make_counter_proposal(id: &str, supersedes: &str) -> Vec<u8> {
633        CounterProposalPayload {
634            proposal_id: id.into(),
635            supersedes_proposal_id: supersedes.into(),
636            title: format!("counter-{id}"),
637            summary: "counter".into(),
638            details: vec![],
639        }
640        .encode_to_vec()
641    }
642
643    #[test]
644    fn empty_proposal_id_rejected() {
645        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
646        let mut session = base_session();
647        let resp = mode
648            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
649            .unwrap();
650        apply(&mut session, resp);
651        let empty = ProposalPayload {
652            proposal_id: "".into(),
653            title: "title".into(),
654            summary: "summary".into(),
655            details: vec![],
656            tags: vec![],
657        }
658        .encode_to_vec();
659        assert_eq!(
660            mode.on_message(&session, &env("agent://seller", "Proposal", empty))
661                .unwrap_err()
662                .to_string(),
663            "InvalidPayload"
664        );
665    }
666
667    #[test]
668    fn counterproposal_requires_valid_supersedes() {
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 resp = mode
676            .on_message(
677                &session,
678                &env("agent://seller", "Proposal", make_proposal("p1")),
679            )
680            .unwrap();
681        apply(&mut session, resp);
682        // Supersedes non-existent proposal
683        let bad_counter = make_counter_proposal("p2", "nonexistent");
684        assert_eq!(
685            mode.on_message(
686                &session,
687                &env("agent://buyer", "CounterProposal", bad_counter)
688            )
689            .unwrap_err()
690            .to_string(),
691            "InvalidPayload"
692        );
693    }
694
695    #[test]
696    fn counterproposal_chain_works() {
697        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
698        let mut session = base_session();
699        let resp = mode
700            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
701            .unwrap();
702        apply(&mut session, resp);
703        let resp = mode
704            .on_message(
705                &session,
706                &env("agent://seller", "Proposal", make_proposal("p1")),
707            )
708            .unwrap();
709        apply(&mut session, resp);
710        let resp = mode
711            .on_message(
712                &session,
713                &env(
714                    "agent://buyer",
715                    "CounterProposal",
716                    make_counter_proposal("p2", "p1"),
717                ),
718            )
719            .unwrap();
720        apply(&mut session, resp);
721        // Chain: p3 supersedes p2
722        mode.on_message(
723            &session,
724            &env(
725                "agent://seller",
726                "CounterProposal",
727                make_counter_proposal("p3", "p2"),
728            ),
729        )
730        .unwrap();
731    }
732
733    #[test]
734    fn non_terminal_reject_does_not_enable_commitment() {
735        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
736        let mut session = base_session();
737        let resp = mode
738            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
739            .unwrap();
740        apply(&mut session, resp);
741        let resp = mode
742            .on_message(
743                &session,
744                &env("agent://seller", "Proposal", make_proposal("p1")),
745            )
746            .unwrap();
747        apply(&mut session, resp);
748        // Non-terminal reject
749        let resp = mode
750            .on_message(
751                &session,
752                &env("agent://buyer", "Reject", make_reject("p1", false)),
753            )
754            .unwrap();
755        apply(&mut session, resp);
756        // Verify the rejection is still recorded in state for audit
757        let state = decode(&session);
758        assert_eq!(state.rejections.len(), 1);
759        assert_eq!(state.rejections[0].proposal_id, "p1");
760        assert!(!state.rejections[0].terminal);
761        assert!(state.terminal_rejections.is_empty());
762        // But commitment should still be blocked
763        assert_eq!(
764            mode.on_message(
765                &session,
766                &env(
767                    "agent://buyer",
768                    "Commitment",
769                    commitment(&session, "proposal.rejected"),
770                ),
771            )
772            .unwrap_err()
773            .to_string(),
774            "InvalidPayload"
775        );
776    }
777
778    #[test]
779    fn non_participant_cannot_propose() {
780        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
781        let session = base_session();
782        let err = mode
783            .authorize_sender(
784                &session,
785                &env("agent://outsider", "Proposal", make_proposal("p1")),
786            )
787            .unwrap_err();
788        assert_eq!(err.to_string(), "Forbidden");
789    }
790
791    #[test]
792    fn commitment_version_mismatch_rejected() {
793        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
794        let mut session = base_session();
795        let resp = mode
796            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
797            .unwrap();
798        apply(&mut session, resp);
799        let resp = mode
800            .on_message(
801                &session,
802                &env("agent://seller", "Proposal", make_proposal("p1")),
803            )
804            .unwrap();
805        apply(&mut session, resp);
806        let resp = mode
807            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
808            .unwrap();
809        apply(&mut session, resp);
810        let resp = mode
811            .on_message(
812                &session,
813                &env("agent://seller", "Accept", make_accept("p1")),
814            )
815            .unwrap();
816        apply(&mut session, resp);
817        let bad = CommitmentPayload {
818            commitment_id: "c1".into(),
819            action: "proposal.accepted".into(),
820            authority_scope: "commercial".into(),
821            reason: "bound".into(),
822            mode_version: "wrong".into(),
823            policy_version: session.policy_version.clone(),
824            configuration_version: session.configuration_version.clone(),
825            outcome_positive: true,
826            supersedes: None,
827        }
828        .encode_to_vec();
829        assert_eq!(
830            mode.on_message(&session, &env("agent://buyer", "Commitment", bad))
831                .unwrap_err()
832                .to_string(),
833            "InvalidPayload"
834        );
835    }
836
837    #[test]
838    fn accept_on_withdrawn_proposal_rejected() {
839        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
840        let mut session = base_session();
841        let resp = mode
842            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
843            .unwrap();
844        apply(&mut session, resp);
845        let resp = mode
846            .on_message(
847                &session,
848                &env("agent://seller", "Proposal", make_proposal("p1")),
849            )
850            .unwrap();
851        apply(&mut session, resp);
852        let resp = mode
853            .on_message(
854                &session,
855                &env("agent://seller", "Withdraw", make_withdraw("p1")),
856            )
857            .unwrap();
858        apply(&mut session, resp);
859        assert_eq!(
860            mode.on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
861                .unwrap_err()
862                .to_string(),
863            "InvalidPayload"
864        );
865    }
866
867    #[test]
868    fn commitment_from_non_initiator_rejected() {
869        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
870        let session = base_session();
871        let err = mode
872            .authorize_sender(
873                &session,
874                &env(
875                    "agent://seller",
876                    "Commitment",
877                    commitment(&session, "proposal.accepted"),
878                ),
879            )
880            .unwrap_err();
881        assert_eq!(err.to_string(), "Forbidden");
882    }
883
884    #[test]
885    fn terminal_rejection_on_different_proposal_survives_withdraw() {
886        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
887        let mut session = base_session();
888        let resp = mode
889            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
890            .unwrap();
891        apply(&mut session, resp);
892        // Seller proposes p1 and p2
893        let resp = mode
894            .on_message(
895                &session,
896                &env("agent://seller", "Proposal", make_proposal("p1")),
897            )
898            .unwrap();
899        apply(&mut session, resp);
900        let resp = mode
901            .on_message(
902                &session,
903                &env("agent://seller", "Proposal", make_proposal("p2")),
904            )
905            .unwrap();
906        apply(&mut session, resp);
907        // Buyer terminally rejects p2
908        let resp = mode
909            .on_message(
910                &session,
911                &env("agent://buyer", "Reject", make_reject("p2", true)),
912            )
913            .unwrap();
914        apply(&mut session, resp);
915        // Seller withdraws p1 — p2's terminal rejection survives
916        let resp = mode
917            .on_message(
918                &session,
919                &env("agent://seller", "Withdraw", make_withdraw("p1")),
920            )
921            .unwrap();
922        apply(&mut session, resp);
923        // Commitment should be ready because p2 still has a terminal rejection
924        mode.on_message(
925            &session,
926            &env(
927                "agent://buyer",
928                "Commitment",
929                commitment(&session, "proposal.rejected"),
930            ),
931        )
932        .unwrap();
933    }
934
935    #[test]
936    fn phase_becomes_converged_when_all_participants_accept_same_live_proposal() {
937        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
938        let mut session = base_session();
939        let resp = mode
940            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
941            .unwrap();
942        apply(&mut session, resp);
943        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
944
945        let resp = mode
946            .on_message(
947                &session,
948                &env("agent://seller", "Proposal", make_proposal("p1")),
949            )
950            .unwrap();
951        apply(&mut session, resp);
952        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
953
954        let resp = mode
955            .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
956            .unwrap();
957        apply(&mut session, resp);
958        assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
959
960        let resp = mode
961            .on_message(
962                &session,
963                &env("agent://seller", "Accept", make_accept("p1")),
964            )
965            .unwrap();
966        apply(&mut session, resp);
967        assert_eq!(decode(&session).phase, ProposalPhase::Converged);
968    }
969
970    #[test]
971    fn terminal_reject_sets_terminal_rejected_phase() {
972        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
973        let mut session = base_session();
974        let resp = mode
975            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
976            .unwrap();
977        apply(&mut session, resp);
978        let resp = mode
979            .on_message(
980                &session,
981                &env("agent://seller", "Proposal", make_proposal("p1")),
982            )
983            .unwrap();
984        apply(&mut session, resp);
985        let resp = mode
986            .on_message(
987                &session,
988                &env("agent://buyer", "Reject", make_reject("p1", true)),
989            )
990            .unwrap();
991        apply(&mut session, resp);
992        assert_eq!(decode(&session).phase, ProposalPhase::TerminalRejected);
993    }
994
995    #[test]
996    fn malformed_counterproposal_payload_rejected() {
997        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
998        let mut session = base_session();
999        let resp = mode
1000            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1001            .unwrap();
1002        apply(&mut session, resp);
1003        assert_eq!(
1004            mode.on_message(
1005                &session,
1006                &env("agent://seller", "CounterProposal", vec![0xff, 0x00])
1007            )
1008            .unwrap_err()
1009            .to_string(),
1010            "InvalidPayload"
1011        );
1012    }
1013
1014    #[test]
1015    fn policy_blocks_counter_proposal_at_submission_when_limit_exceeded() {
1016        // RFC-MACP-0012: max_rounds is enforced both at submission and commitment time.
1017        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1018        let mut session = base_session();
1019        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1020            policy_id: "test-limited".into(),
1021            mode: "macp.mode.proposal.v1".into(),
1022            description: "limited".into(),
1023            rules: serde_json::json!({
1024                "counter_proposal": { "max_rounds": 1 }
1025            }),
1026            schema_version: 1,
1027        });
1028        let resp = mode
1029            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1030            .unwrap();
1031        apply(&mut session, resp);
1032        // Seller proposes p1
1033        let resp = mode
1034            .on_message(
1035                &session,
1036                &env("agent://seller", "Proposal", make_proposal("p1")),
1037            )
1038            .unwrap();
1039        apply(&mut session, resp);
1040        // Buyer counter-proposes p2 (supersedes p1) -- 1st counter-proposal: allowed
1041        let resp = mode
1042            .on_message(
1043                &session,
1044                &env(
1045                    "agent://buyer",
1046                    "CounterProposal",
1047                    make_counter_proposal("p2", "p1"),
1048                ),
1049            )
1050            .unwrap();
1051        apply(&mut session, resp);
1052        // Seller counter-proposes p3 (supersedes p2) -- 2nd counter-proposal:
1053        // REJECTED at submission time (max_rounds=1, already 1 counter-proposal)
1054        let err = mode
1055            .on_message(
1056                &session,
1057                &env(
1058                    "agent://seller",
1059                    "CounterProposal",
1060                    make_counter_proposal("p3", "p2"),
1061                ),
1062            )
1063            .unwrap_err();
1064        assert_eq!(err.to_string(), "InvalidPayload");
1065    }
1066
1067    // --- CounterProposal does NOT retire original ---
1068
1069    #[test]
1070    fn counter_proposal_does_not_retire_original() {
1071        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1072        let mut session = base_session();
1073        let resp = mode
1074            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1075            .unwrap();
1076        apply(&mut session, resp);
1077        // Seller proposes p1
1078        let resp = mode
1079            .on_message(
1080                &session,
1081                &env("agent://seller", "Proposal", make_proposal("p1")),
1082            )
1083            .unwrap();
1084        apply(&mut session, resp);
1085        // Buyer counter-proposes p2, superseding p1
1086        let resp = mode
1087            .on_message(
1088                &session,
1089                &env(
1090                    "agent://buyer",
1091                    "CounterProposal",
1092                    make_counter_proposal("p2", "p1"),
1093                ),
1094            )
1095            .unwrap();
1096        apply(&mut session, resp);
1097        // Verify both proposals are still live in the mode state
1098        let state = decode(&session);
1099        assert_eq!(state.proposals.len(), 2);
1100        assert_eq!(state.proposals["p1"].disposition, ProposalDisposition::Live);
1101        assert_eq!(state.proposals["p2"].disposition, ProposalDisposition::Live);
1102        assert_eq!(
1103            state.proposals["p2"].supersedes_proposal_id,
1104            Some("p1".into())
1105        );
1106    }
1107
1108    #[test]
1109    fn reject_withdrawn_proposal_fails() {
1110        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1111        let mut session = base_session();
1112        let resp = mode
1113            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1114            .unwrap();
1115        apply(&mut session, resp);
1116        let resp = mode
1117            .on_message(
1118                &session,
1119                &env("agent://buyer", "Proposal", make_proposal("p1")),
1120            )
1121            .unwrap();
1122        apply(&mut session, resp);
1123        // Withdraw p1
1124        let resp = mode
1125            .on_message(
1126                &session,
1127                &env("agent://buyer", "Withdraw", make_withdraw("p1")),
1128            )
1129            .unwrap();
1130        apply(&mut session, resp);
1131        // Reject on withdrawn proposal should fail
1132        assert_eq!(
1133            mode.on_message(
1134                &session,
1135                &env("agent://seller", "Reject", make_reject("p1", false))
1136            )
1137            .unwrap_err()
1138            .to_string(),
1139            "InvalidPayload"
1140        );
1141    }
1142
1143    #[test]
1144    fn withdraw_empty_proposal_id_rejected() {
1145        let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1146        let mut session = base_session();
1147        let resp = mode
1148            .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1149            .unwrap();
1150        apply(&mut session, resp);
1151        let empty_withdraw = WithdrawPayload {
1152            proposal_id: String::new(),
1153            reason: "empty".into(),
1154        }
1155        .encode_to_vec();
1156        assert_eq!(
1157            mode.on_message(&session, &env("agent://buyer", "Withdraw", empty_withdraw))
1158                .unwrap_err()
1159                .to_string(),
1160            "InvalidPayload"
1161        );
1162    }
1163}