1use crate::mode::util::{
2 check_commitment_authority, is_declared_participant, participants_all_accept,
3 validate_commitment_payload_for_session,
4};
5use crate::mode::{Mode, ModeResponse};
6use macp_core::error::MacpError;
7use macp_core::session::Session;
8use macp_pb::pb::Envelope;
9use macp_pb::proposal_pb::{
10 AcceptPayload, CounterProposalPayload, ProposalPayload, RejectPayload, WithdrawPayload,
11};
12use prost::Message;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub enum ProposalDisposition {
18 Live,
19 Withdrawn,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
23pub enum ProposalPhase {
24 #[default]
25 Negotiating,
26 Converged,
27 TerminalRejected,
28 Committed,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProposalRecord {
33 pub proposal_id: String,
34 pub title: String,
35 pub summary: String,
36 pub details: Vec<u8>,
37 pub tags: Vec<String>,
38 pub proposer: String,
39 pub supersedes_proposal_id: Option<String>,
40 pub disposition: ProposalDisposition,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TerminalRejectRecord {
45 pub proposal_id: String,
46 pub sender: String,
47 pub reason: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RejectRecord {
52 pub proposal_id: String,
53 pub sender: String,
54 pub reason: String,
55 pub terminal: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct ProposalState {
60 pub proposals: BTreeMap<String, ProposalRecord>,
61 pub accepts: BTreeMap<String, String>,
62 pub terminal_rejections: Vec<TerminalRejectRecord>,
63 #[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 serde_json::to_vec(state).expect("ProposalState is always serializable")
82 }
83
84 fn decode_state(data: &[u8]) -> Result<ProposalState, MacpError> {
85 serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState)
86 }
87
88 fn live_proposal<'a>(
89 state: &'a ProposalState,
90 proposal_id: &str,
91 ) -> Option<&'a ProposalRecord> {
92 state
93 .proposals
94 .get(proposal_id)
95 .filter(|record| record.disposition == ProposalDisposition::Live)
96 }
97
98 fn refresh_phase(session: &Session, state: &mut ProposalState) {
99 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 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 if let Some(ref policy) = session.policy_definition {
361 let counter_count = state
362 .proposals
363 .values()
364 .filter(|p| p.supersedes_proposal_id.is_some())
365 .count();
366 let decision = self
367 .evaluator
368 .evaluate_proposal_commitment(policy, counter_count);
369 if let macp_core::policy::PolicyDecision::Deny { reasons } = decision {
370 tracing::warn!(
371 session_id = %session.session_id,
372 policy_id = %policy.policy_id,
373 reasons = ?reasons,
374 "policy denied commitment"
375 );
376 return Err(MacpError::PolicyDenied { reasons });
377 }
378 }
379 state.phase = ProposalPhase::Committed;
380 Ok(ModeResponse::PersistAndResolve {
381 state: Self::encode_state(&state),
382 resolution: env.payload.clone(),
383 })
384 }
385 _ => Err(MacpError::InvalidPayload),
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use macp_core::session::{Session, SessionState};
394 use macp_pb::pb::CommitmentPayload;
395 use std::collections::HashSet;
396
397 fn base_session() -> Session {
398 Session {
399 session_id: "s1".into(),
400 state: SessionState::Open,
401 ttl_expiry: i64::MAX,
402 ttl_ms: 60_000,
403 started_at_unix_ms: 0,
404 resolution: None,
405 mode: "macp.mode.proposal.v1".into(),
406 mode_state: vec![],
407 participants: vec!["agent://buyer".into(), "agent://seller".into()],
408 seen_message_ids: HashSet::new(),
409 intent: String::new(),
410 mode_version: "1.0.0".into(),
411 configuration_version: "cfg-1".into(),
412 policy_version: "policy-1".into(),
413 context_id: String::new(),
414 extensions: std::collections::HashMap::new(),
415 roots: vec![],
416 initiator_sender: "agent://buyer".into(),
417 participant_message_counts: std::collections::HashMap::new(),
418 participant_last_seen: std::collections::HashMap::new(),
419 policy_definition: None,
420 suspended_at_ms: None,
421 accumulated_suspended_ms: 0,
422 }
423 }
424
425 fn decode(session: &Session) -> ProposalState {
426 serde_json::from_slice(&session.mode_state).unwrap()
427 }
428
429 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
430 Envelope {
431 macp_version: "1.0".into(),
432 mode: "macp.mode.proposal.v1".into(),
433 message_type: message_type.into(),
434 message_id: format!("{}-{}", sender, message_type),
435 session_id: "s1".into(),
436 sender: sender.into(),
437 timestamp_unix_ms: 0,
438 payload,
439 }
440 }
441
442 fn commitment(session: &Session, action: &str) -> Vec<u8> {
443 let outcome_positive = !action.contains("rejected")
444 && !action.contains("failed")
445 && !action.contains("declined");
446 CommitmentPayload {
447 commitment_id: "c1".into(),
448 action: action.into(),
449 authority_scope: "commercial".into(),
450 reason: "bound".into(),
451 mode_version: session.mode_version.clone(),
452 policy_version: session.policy_version.clone(),
453 configuration_version: session.configuration_version.clone(),
454 outcome_positive,
455 supersedes: None,
456 }
457 .encode_to_vec()
458 }
459
460 fn apply(session: &mut Session, result: ModeResponse) {
461 match result {
462 ModeResponse::PersistState(data) => session.mode_state = data,
463 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
464 _ => {}
465 }
466 }
467
468 fn make_proposal(id: &str) -> Vec<u8> {
469 ProposalPayload {
470 proposal_id: id.into(),
471 title: format!("offer-{id}"),
472 summary: "summary".into(),
473 details: vec![],
474 tags: vec![],
475 }
476 .encode_to_vec()
477 }
478
479 fn make_accept(id: &str) -> Vec<u8> {
480 AcceptPayload {
481 proposal_id: id.into(),
482 reason: String::new(),
483 }
484 .encode_to_vec()
485 }
486
487 fn make_reject(id: &str, terminal: bool) -> Vec<u8> {
488 RejectPayload {
489 proposal_id: id.into(),
490 terminal,
491 reason: "no".into(),
492 }
493 .encode_to_vec()
494 }
495
496 fn make_withdraw(id: &str) -> Vec<u8> {
497 WithdrawPayload {
498 proposal_id: id.into(),
499 reason: "changed mind".into(),
500 }
501 .encode_to_vec()
502 }
503
504 #[test]
505 fn session_start_requires_participants() {
506 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
507 let mut session = base_session();
508 session.participants.clear();
509 assert_eq!(
510 mode.on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
511 .unwrap_err()
512 .to_string(),
513 "InvalidPayload"
514 );
515 }
516
517 #[test]
518 fn commitment_requires_acceptance_convergence() {
519 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
520 let mut session = base_session();
521 let resp = mode
522 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
523 .unwrap();
524 apply(&mut session, resp);
525 let resp = mode
526 .on_message(
527 &session,
528 &env("agent://seller", "Proposal", make_proposal("p1")),
529 )
530 .unwrap();
531 apply(&mut session, resp);
532
533 assert_eq!(
534 mode.on_message(
535 &session,
536 &env(
537 "agent://buyer",
538 "Commitment",
539 commitment(&session, "proposal.accepted"),
540 ),
541 )
542 .unwrap_err()
543 .to_string(),
544 "InvalidPayload"
545 );
546
547 let resp = mode
548 .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
549 .unwrap();
550 apply(&mut session, resp);
551 let resp = mode
552 .on_message(
553 &session,
554 &env("agent://seller", "Accept", make_accept("p1")),
555 )
556 .unwrap();
557 apply(&mut session, resp);
558 mode.on_message(
559 &session,
560 &env(
561 "agent://buyer",
562 "Commitment",
563 commitment(&session, "proposal.accepted"),
564 ),
565 )
566 .unwrap();
567 }
568
569 #[test]
570 fn terminal_rejection_allows_negative_commitment() {
571 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
572 let mut session = base_session();
573 let resp = mode
574 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
575 .unwrap();
576 apply(&mut session, resp);
577 let resp = mode
578 .on_message(
579 &session,
580 &env("agent://seller", "Proposal", make_proposal("p1")),
581 )
582 .unwrap();
583 apply(&mut session, resp);
584 let resp = mode
585 .on_message(
586 &session,
587 &env("agent://buyer", "Reject", make_reject("p1", true)),
588 )
589 .unwrap();
590 apply(&mut session, resp);
591 mode.on_message(
592 &session,
593 &env(
594 "agent://buyer",
595 "Commitment",
596 commitment(&session, "proposal.rejected"),
597 ),
598 )
599 .unwrap();
600 }
601
602 #[test]
603 fn withdraw_clears_terminal_rejections() {
604 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
605 let mut session = base_session();
606 let resp = mode
607 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
608 .unwrap();
609 apply(&mut session, resp);
610 let resp = mode
612 .on_message(
613 &session,
614 &env("agent://seller", "Proposal", make_proposal("p1")),
615 )
616 .unwrap();
617 apply(&mut session, resp);
618 let resp = mode
620 .on_message(
621 &session,
622 &env("agent://buyer", "Reject", make_reject("p1", true)),
623 )
624 .unwrap();
625 apply(&mut session, resp);
626 let resp = mode
628 .on_message(
629 &session,
630 &env("agent://seller", "Withdraw", make_withdraw("p1")),
631 )
632 .unwrap();
633 apply(&mut session, resp);
634 let resp = mode
636 .on_message(
637 &session,
638 &env("agent://seller", "Proposal", make_proposal("p2")),
639 )
640 .unwrap();
641 apply(&mut session, resp);
642 let err = mode
644 .on_message(
645 &session,
646 &env(
647 "agent://buyer",
648 "Commitment",
649 commitment(&session, "proposal.rejected"),
650 ),
651 )
652 .unwrap_err();
653 assert_eq!(err.to_string(), "InvalidPayload");
654 }
655
656 fn make_counter_proposal(id: &str, supersedes: &str) -> Vec<u8> {
657 CounterProposalPayload {
658 proposal_id: id.into(),
659 supersedes_proposal_id: supersedes.into(),
660 title: format!("counter-{id}"),
661 summary: "counter".into(),
662 details: vec![],
663 }
664 .encode_to_vec()
665 }
666
667 #[test]
668 fn empty_proposal_id_rejected() {
669 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
670 let mut session = base_session();
671 let resp = mode
672 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
673 .unwrap();
674 apply(&mut session, resp);
675 let empty = ProposalPayload {
676 proposal_id: "".into(),
677 title: "title".into(),
678 summary: "summary".into(),
679 details: vec![],
680 tags: vec![],
681 }
682 .encode_to_vec();
683 assert_eq!(
684 mode.on_message(&session, &env("agent://seller", "Proposal", empty))
685 .unwrap_err()
686 .to_string(),
687 "InvalidPayload"
688 );
689 }
690
691 #[test]
692 fn counterproposal_requires_valid_supersedes() {
693 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
694 let mut session = base_session();
695 let resp = mode
696 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
697 .unwrap();
698 apply(&mut session, resp);
699 let resp = mode
700 .on_message(
701 &session,
702 &env("agent://seller", "Proposal", make_proposal("p1")),
703 )
704 .unwrap();
705 apply(&mut session, resp);
706 let bad_counter = make_counter_proposal("p2", "nonexistent");
708 assert_eq!(
709 mode.on_message(
710 &session,
711 &env("agent://buyer", "CounterProposal", bad_counter)
712 )
713 .unwrap_err()
714 .to_string(),
715 "InvalidPayload"
716 );
717 }
718
719 #[test]
720 fn counterproposal_chain_works() {
721 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
722 let mut session = base_session();
723 let resp = mode
724 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
725 .unwrap();
726 apply(&mut session, resp);
727 let resp = mode
728 .on_message(
729 &session,
730 &env("agent://seller", "Proposal", make_proposal("p1")),
731 )
732 .unwrap();
733 apply(&mut session, resp);
734 let resp = mode
735 .on_message(
736 &session,
737 &env(
738 "agent://buyer",
739 "CounterProposal",
740 make_counter_proposal("p2", "p1"),
741 ),
742 )
743 .unwrap();
744 apply(&mut session, resp);
745 mode.on_message(
747 &session,
748 &env(
749 "agent://seller",
750 "CounterProposal",
751 make_counter_proposal("p3", "p2"),
752 ),
753 )
754 .unwrap();
755 }
756
757 #[test]
758 fn non_terminal_reject_does_not_enable_commitment() {
759 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
760 let mut session = base_session();
761 let resp = mode
762 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
763 .unwrap();
764 apply(&mut session, resp);
765 let resp = mode
766 .on_message(
767 &session,
768 &env("agent://seller", "Proposal", make_proposal("p1")),
769 )
770 .unwrap();
771 apply(&mut session, resp);
772 let resp = mode
774 .on_message(
775 &session,
776 &env("agent://buyer", "Reject", make_reject("p1", false)),
777 )
778 .unwrap();
779 apply(&mut session, resp);
780 let state = decode(&session);
782 assert_eq!(state.rejections.len(), 1);
783 assert_eq!(state.rejections[0].proposal_id, "p1");
784 assert!(!state.rejections[0].terminal);
785 assert!(state.terminal_rejections.is_empty());
786 assert_eq!(
788 mode.on_message(
789 &session,
790 &env(
791 "agent://buyer",
792 "Commitment",
793 commitment(&session, "proposal.rejected"),
794 ),
795 )
796 .unwrap_err()
797 .to_string(),
798 "InvalidPayload"
799 );
800 }
801
802 #[test]
803 fn non_participant_cannot_propose() {
804 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
805 let session = base_session();
806 let err = mode
807 .authorize_sender(
808 &session,
809 &env("agent://outsider", "Proposal", make_proposal("p1")),
810 )
811 .unwrap_err();
812 assert_eq!(err.to_string(), "Forbidden");
813 }
814
815 #[test]
816 fn commitment_version_mismatch_rejected() {
817 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
818 let mut session = base_session();
819 let resp = mode
820 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
821 .unwrap();
822 apply(&mut session, resp);
823 let resp = mode
824 .on_message(
825 &session,
826 &env("agent://seller", "Proposal", make_proposal("p1")),
827 )
828 .unwrap();
829 apply(&mut session, resp);
830 let resp = mode
831 .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
832 .unwrap();
833 apply(&mut session, resp);
834 let resp = mode
835 .on_message(
836 &session,
837 &env("agent://seller", "Accept", make_accept("p1")),
838 )
839 .unwrap();
840 apply(&mut session, resp);
841 let bad = CommitmentPayload {
842 commitment_id: "c1".into(),
843 action: "proposal.accepted".into(),
844 authority_scope: "commercial".into(),
845 reason: "bound".into(),
846 mode_version: "wrong".into(),
847 policy_version: session.policy_version.clone(),
848 configuration_version: session.configuration_version.clone(),
849 outcome_positive: true,
850 supersedes: None,
851 }
852 .encode_to_vec();
853 assert_eq!(
854 mode.on_message(&session, &env("agent://buyer", "Commitment", bad))
855 .unwrap_err()
856 .to_string(),
857 "InvalidPayload"
858 );
859 }
860
861 #[test]
862 fn accept_on_withdrawn_proposal_rejected() {
863 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
864 let mut session = base_session();
865 let resp = mode
866 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
867 .unwrap();
868 apply(&mut session, resp);
869 let resp = mode
870 .on_message(
871 &session,
872 &env("agent://seller", "Proposal", make_proposal("p1")),
873 )
874 .unwrap();
875 apply(&mut session, resp);
876 let resp = mode
877 .on_message(
878 &session,
879 &env("agent://seller", "Withdraw", make_withdraw("p1")),
880 )
881 .unwrap();
882 apply(&mut session, resp);
883 assert_eq!(
884 mode.on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
885 .unwrap_err()
886 .to_string(),
887 "InvalidPayload"
888 );
889 }
890
891 #[test]
892 fn commitment_from_non_initiator_rejected() {
893 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
894 let session = base_session();
895 let err = mode
896 .authorize_sender(
897 &session,
898 &env(
899 "agent://seller",
900 "Commitment",
901 commitment(&session, "proposal.accepted"),
902 ),
903 )
904 .unwrap_err();
905 assert_eq!(err.to_string(), "Forbidden");
906 }
907
908 #[test]
909 fn terminal_rejection_on_different_proposal_survives_withdraw() {
910 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
911 let mut session = base_session();
912 let resp = mode
913 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
914 .unwrap();
915 apply(&mut session, resp);
916 let resp = mode
918 .on_message(
919 &session,
920 &env("agent://seller", "Proposal", make_proposal("p1")),
921 )
922 .unwrap();
923 apply(&mut session, resp);
924 let resp = mode
925 .on_message(
926 &session,
927 &env("agent://seller", "Proposal", make_proposal("p2")),
928 )
929 .unwrap();
930 apply(&mut session, resp);
931 let resp = mode
933 .on_message(
934 &session,
935 &env("agent://buyer", "Reject", make_reject("p2", true)),
936 )
937 .unwrap();
938 apply(&mut session, resp);
939 let resp = mode
941 .on_message(
942 &session,
943 &env("agent://seller", "Withdraw", make_withdraw("p1")),
944 )
945 .unwrap();
946 apply(&mut session, resp);
947 mode.on_message(
949 &session,
950 &env(
951 "agent://buyer",
952 "Commitment",
953 commitment(&session, "proposal.rejected"),
954 ),
955 )
956 .unwrap();
957 }
958
959 #[test]
960 fn phase_becomes_converged_when_all_participants_accept_same_live_proposal() {
961 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
962 let mut session = base_session();
963 let resp = mode
964 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
965 .unwrap();
966 apply(&mut session, resp);
967 assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
968
969 let resp = mode
970 .on_message(
971 &session,
972 &env("agent://seller", "Proposal", make_proposal("p1")),
973 )
974 .unwrap();
975 apply(&mut session, resp);
976 assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
977
978 let resp = mode
979 .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1")))
980 .unwrap();
981 apply(&mut session, resp);
982 assert_eq!(decode(&session).phase, ProposalPhase::Negotiating);
983
984 let resp = mode
985 .on_message(
986 &session,
987 &env("agent://seller", "Accept", make_accept("p1")),
988 )
989 .unwrap();
990 apply(&mut session, resp);
991 assert_eq!(decode(&session).phase, ProposalPhase::Converged);
992 }
993
994 #[test]
995 fn terminal_reject_sets_terminal_rejected_phase() {
996 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
997 let mut session = base_session();
998 let resp = mode
999 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1000 .unwrap();
1001 apply(&mut session, resp);
1002 let resp = mode
1003 .on_message(
1004 &session,
1005 &env("agent://seller", "Proposal", make_proposal("p1")),
1006 )
1007 .unwrap();
1008 apply(&mut session, resp);
1009 let resp = mode
1010 .on_message(
1011 &session,
1012 &env("agent://buyer", "Reject", make_reject("p1", true)),
1013 )
1014 .unwrap();
1015 apply(&mut session, resp);
1016 assert_eq!(decode(&session).phase, ProposalPhase::TerminalRejected);
1017 }
1018
1019 #[test]
1020 fn malformed_counterproposal_payload_rejected() {
1021 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1022 let mut session = base_session();
1023 let resp = mode
1024 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1025 .unwrap();
1026 apply(&mut session, resp);
1027 assert_eq!(
1028 mode.on_message(
1029 &session,
1030 &env("agent://seller", "CounterProposal", vec![0xff, 0x00])
1031 )
1032 .unwrap_err()
1033 .to_string(),
1034 "InvalidPayload"
1035 );
1036 }
1037
1038 #[test]
1039 fn policy_blocks_counter_proposal_at_submission_when_limit_exceeded() {
1040 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1042 let mut session = base_session();
1043 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1044 policy_id: "test-limited".into(),
1045 mode: "macp.mode.proposal.v1".into(),
1046 description: "limited".into(),
1047 rules: serde_json::json!({
1048 "counter_proposal": { "max_rounds": 1 }
1049 }),
1050 schema_version: 1,
1051 });
1052 let resp = mode
1053 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1054 .unwrap();
1055 apply(&mut session, resp);
1056 let resp = mode
1058 .on_message(
1059 &session,
1060 &env("agent://seller", "Proposal", make_proposal("p1")),
1061 )
1062 .unwrap();
1063 apply(&mut session, resp);
1064 let resp = mode
1066 .on_message(
1067 &session,
1068 &env(
1069 "agent://buyer",
1070 "CounterProposal",
1071 make_counter_proposal("p2", "p1"),
1072 ),
1073 )
1074 .unwrap();
1075 apply(&mut session, resp);
1076 let err = mode
1079 .on_message(
1080 &session,
1081 &env(
1082 "agent://seller",
1083 "CounterProposal",
1084 make_counter_proposal("p3", "p2"),
1085 ),
1086 )
1087 .unwrap_err();
1088 assert_eq!(err.to_string(), "InvalidPayload");
1089 }
1090
1091 #[test]
1094 fn counter_proposal_does_not_retire_original() {
1095 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1096 let mut session = base_session();
1097 let resp = mode
1098 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1099 .unwrap();
1100 apply(&mut session, resp);
1101 let resp = mode
1103 .on_message(
1104 &session,
1105 &env("agent://seller", "Proposal", make_proposal("p1")),
1106 )
1107 .unwrap();
1108 apply(&mut session, resp);
1109 let resp = mode
1111 .on_message(
1112 &session,
1113 &env(
1114 "agent://buyer",
1115 "CounterProposal",
1116 make_counter_proposal("p2", "p1"),
1117 ),
1118 )
1119 .unwrap();
1120 apply(&mut session, resp);
1121 let state = decode(&session);
1123 assert_eq!(state.proposals.len(), 2);
1124 assert_eq!(state.proposals["p1"].disposition, ProposalDisposition::Live);
1125 assert_eq!(state.proposals["p2"].disposition, ProposalDisposition::Live);
1126 assert_eq!(
1127 state.proposals["p2"].supersedes_proposal_id,
1128 Some("p1".into())
1129 );
1130 }
1131
1132 #[test]
1133 fn reject_withdrawn_proposal_fails() {
1134 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1135 let mut session = base_session();
1136 let resp = mode
1137 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1138 .unwrap();
1139 apply(&mut session, resp);
1140 let resp = mode
1141 .on_message(
1142 &session,
1143 &env("agent://buyer", "Proposal", make_proposal("p1")),
1144 )
1145 .unwrap();
1146 apply(&mut session, resp);
1147 let resp = mode
1149 .on_message(
1150 &session,
1151 &env("agent://buyer", "Withdraw", make_withdraw("p1")),
1152 )
1153 .unwrap();
1154 apply(&mut session, resp);
1155 assert_eq!(
1157 mode.on_message(
1158 &session,
1159 &env("agent://seller", "Reject", make_reject("p1", false))
1160 )
1161 .unwrap_err()
1162 .to_string(),
1163 "InvalidPayload"
1164 );
1165 }
1166
1167 #[test]
1168 fn withdraw_empty_proposal_id_rejected() {
1169 let mode = ProposalMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1170 let mut session = base_session();
1171 let resp = mode
1172 .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![]))
1173 .unwrap();
1174 apply(&mut session, resp);
1175 let empty_withdraw = WithdrawPayload {
1176 proposal_id: String::new(),
1177 reason: "empty".into(),
1178 }
1179 .encode_to_vec();
1180 assert_eq!(
1181 mode.on_message(&session, &env("agent://buyer", "Withdraw", empty_withdraw))
1182 .unwrap_err()
1183 .to_string(),
1184 "InvalidPayload"
1185 );
1186 }
1187}