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