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)]
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#[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#[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#[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 #[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 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 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 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 state
189 .accepts
190 .get(&session.initiator_sender)
191 .map(String::as_str)
192 == Some(proposal_id)
193 }
194 _ => {
195 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 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 if Self::live_proposal(&state, &payload.proposal_id).is_none() {
335 return Err(MacpError::InvalidPayload);
336 }
337 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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}