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 #[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 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 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 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 state
154 .accepts
155 .get(&session.initiator_sender)
156 .map(String::as_str)
157 == Some(proposal_id)
158 }
159 _ => {
160 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 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 if Self::live_proposal(&state, &payload.proposal_id).is_none() {
300 return Err(MacpError::InvalidPayload);
301 }
302 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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}