1use crate::mode::util::{
2 check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session,
3};
4use crate::mode::{Mode, ModeResponse};
5use macp_core::error::MacpError;
6use macp_core::session::Session;
7use macp_pb::handoff_pb::{
8 HandoffAcceptPayload, HandoffContextPayload, HandoffDeclinePayload, HandoffOfferPayload,
9};
10use macp_pb::pb::Envelope;
11use prost::Message;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub enum HandoffDisposition {
17 Offered,
18 Accepted,
19 Declined,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct HandoffOfferRecord {
24 pub handoff_id: String,
25 pub target_participant: String,
26 pub scope: String,
27 pub reason: String,
28 pub offered_by: String,
29 pub disposition: HandoffDisposition,
30 pub accepted_by: Option<String>,
31 pub declined_by: Option<String>,
32 pub outcome_reason: Option<String>,
33 #[serde(default)]
34 pub offered_at_ms: i64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct HandoffContextRecord {
39 pub content_type: String,
40 pub context: Vec<u8>,
41 pub sender: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct HandoffState {
46 pub offers: BTreeMap<String, HandoffOfferRecord>,
47 pub contexts: BTreeMap<String, Vec<HandoffContextRecord>>,
48}
49
50pub struct HandoffMode {
51 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
52}
53
54impl HandoffMode {
55 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
57 Self { evaluator }
58 }
59
60 fn encode_state(state: &HandoffState) -> Vec<u8> {
61 serde_json::to_vec(state).expect("HandoffState is always serializable")
62 }
63
64 fn decode_state(data: &[u8]) -> Result<HandoffState, MacpError> {
65 serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState)
66 }
67
68 fn commitment_ready(state: &HandoffState) -> bool {
69 state.offers.values().any(|offer| {
70 offer.disposition == HandoffDisposition::Accepted
71 || offer.disposition == HandoffDisposition::Declined
72 })
73 }
74}
75
76impl Mode for HandoffMode {
77 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
78 match env.message_type.as_str() {
79 "Commitment" => check_commitment_authority(session, &env.sender),
80 "HandoffOffer" if env.sender == session.initiator_sender => Ok(()),
82 "HandoffOffer" => Err(MacpError::Forbidden),
83 "HandoffContext" if is_declared_participant(&session.participants, &env.sender) => {
85 Ok(())
86 }
87 _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
88 _ => Err(MacpError::Forbidden),
89 }
90 }
91
92 fn on_session_start(
93 &self,
94 session: &Session,
95 _env: &Envelope,
96 ) -> Result<ModeResponse, MacpError> {
97 if session.participants.len() < 2 {
98 return Err(MacpError::InvalidPayload);
99 }
100 if !session
101 .participants
102 .iter()
103 .any(|p| p == &session.initiator_sender)
104 {
105 return Err(MacpError::InvalidPayload);
106 }
107 Ok(ModeResponse::PersistState(Self::encode_state(
108 &HandoffState::default(),
109 )))
110 }
111
112 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
113 let mut state = if session.mode_state.is_empty() {
114 HandoffState::default()
115 } else {
116 Self::decode_state(&session.mode_state)?
117 };
118
119 match env.message_type.as_str() {
120 "HandoffOffer" => {
121 let payload = HandoffOfferPayload::decode(&*env.payload)
122 .map_err(|_| MacpError::InvalidPayload)?;
123 if payload.handoff_id.is_empty()
126 || payload.target_participant.is_empty()
127 || state.offers.contains_key(&payload.handoff_id)
128 || !is_declared_participant(&session.participants, &payload.target_participant)
129 || payload.target_participant == env.sender
130 || state
131 .offers
132 .values()
133 .any(|o| o.disposition == HandoffDisposition::Offered)
134 || state
135 .offers
136 .values()
137 .any(|o| o.disposition == HandoffDisposition::Accepted)
138 {
139 return Err(MacpError::InvalidPayload);
140 }
141 state.offers.insert(
142 payload.handoff_id.clone(),
143 HandoffOfferRecord {
144 handoff_id: payload.handoff_id,
145 target_participant: payload.target_participant,
146 scope: payload.scope,
147 reason: payload.reason,
148 offered_by: env.sender.clone(),
149 disposition: HandoffDisposition::Offered,
150 accepted_by: None,
151 declined_by: None,
152 outcome_reason: None,
153 offered_at_ms: env.timestamp_unix_ms,
154 },
155 );
156 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
157 }
158 "HandoffContext" => {
159 let payload = HandoffContextPayload::decode(&*env.payload)
160 .map_err(|_| MacpError::InvalidPayload)?;
161 let offer = state
162 .offers
163 .get(&payload.handoff_id)
164 .ok_or(MacpError::InvalidPayload)?;
165 if offer.offered_by != env.sender {
166 return Err(MacpError::Forbidden);
167 }
168 state
171 .contexts
172 .entry(payload.handoff_id)
173 .or_default()
174 .push(HandoffContextRecord {
175 content_type: payload.content_type,
176 context: payload.context,
177 sender: env.sender.clone(),
178 });
179 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
180 }
181 "HandoffAccept" => {
182 let payload = HandoffAcceptPayload::decode(&*env.payload)
183 .map_err(|_| MacpError::InvalidPayload)?;
184 let offer = state
185 .offers
186 .get_mut(&payload.handoff_id)
187 .ok_or(MacpError::InvalidPayload)?;
188 if offer.target_participant != env.sender {
189 return Err(MacpError::Forbidden);
190 }
191 if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender {
192 return Err(MacpError::InvalidPayload);
193 }
194 if offer.disposition != HandoffDisposition::Offered {
195 return Err(MacpError::InvalidPayload);
196 }
197 offer.disposition = HandoffDisposition::Accepted;
198 offer.accepted_by = Some(env.sender.clone());
199 offer.outcome_reason = Some(payload.reason);
200 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
201 }
202 "HandoffDecline" => {
203 let payload = HandoffDeclinePayload::decode(&*env.payload)
204 .map_err(|_| MacpError::InvalidPayload)?;
205 let offer = state
206 .offers
207 .get_mut(&payload.handoff_id)
208 .ok_or(MacpError::InvalidPayload)?;
209 if offer.target_participant != env.sender {
210 return Err(MacpError::Forbidden);
211 }
212 if !payload.declined_by.is_empty() && payload.declined_by != env.sender {
213 return Err(MacpError::InvalidPayload);
214 }
215 if offer.disposition != HandoffDisposition::Offered {
216 return Err(MacpError::InvalidPayload);
217 }
218 offer.disposition = HandoffDisposition::Declined;
219 offer.declined_by = Some(env.sender.clone());
220 offer.outcome_reason = Some(payload.reason);
221 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
222 }
223 "Commitment" => {
224 validate_commitment_payload_for_session(session, &env.payload)?;
225 if let Some(ref policy) = session.policy_definition {
227 let rules: macp_core::policy::rules::HandoffPolicyRules =
228 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
229 if rules.acceptance.implicit_accept_timeout_ms > 0 {
230 let now_ms = env.timestamp_unix_ms;
232 let timeout = rules.acceptance.implicit_accept_timeout_ms as i64;
233 for offer in state.offers.values_mut() {
234 if offer.disposition == HandoffDisposition::Offered
235 && offer.offered_at_ms > 0
236 && (now_ms - offer.offered_at_ms) >= timeout
237 {
238 offer.disposition = HandoffDisposition::Accepted;
239 offer.accepted_by = Some(offer.target_participant.clone());
240 offer.outcome_reason = Some("implicit accept (timeout)".into());
241 }
242 }
243 }
244 }
245 if !Self::commitment_ready(&state) {
246 return Err(MacpError::InvalidPayload);
247 }
248 if let Some(ref policy) = session.policy_definition {
250 let decision = self.evaluator.evaluate_handoff_commitment(policy);
251 if let macp_core::policy::PolicyDecision::Deny { reasons } = decision {
252 tracing::warn!(
253 session_id = %session.session_id,
254 policy_id = %policy.policy_id,
255 reasons = ?reasons,
256 "policy denied commitment"
257 );
258 return Err(MacpError::PolicyDenied { reasons });
259 }
260 }
261 Ok(ModeResponse::PersistAndResolve {
262 state: Self::encode_state(&state),
263 resolution: env.payload.clone(),
264 })
265 }
266 _ => Err(MacpError::InvalidPayload),
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use macp_core::session::{Session, SessionState};
275 use macp_pb::pb::CommitmentPayload;
276 use std::collections::HashSet;
277
278 fn base_session() -> Session {
279 Session {
280 session_id: "s1".into(),
281 state: SessionState::Open,
282 ttl_expiry: i64::MAX,
283 ttl_ms: 60_000,
284 started_at_unix_ms: 0,
285 resolution: None,
286 mode: "macp.mode.handoff.v1".into(),
287 mode_state: vec![],
288 participants: vec!["owner".into(), "target".into()],
289 seen_message_ids: HashSet::new(),
290 intent: String::new(),
291 mode_version: "1.0.0".into(),
292 configuration_version: "config".into(),
293 policy_version: "policy".into(),
294 context_id: String::new(),
295 extensions: std::collections::HashMap::new(),
296 roots: vec![],
297 initiator_sender: "owner".into(),
298 participant_message_counts: std::collections::HashMap::new(),
299 participant_last_seen: std::collections::HashMap::new(),
300 policy_definition: None,
301 suspended_at_ms: None,
302 accumulated_suspended_ms: 0,
303 }
304 }
305
306 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
307 Envelope {
308 macp_version: "1.0".into(),
309 mode: "macp.mode.handoff.v1".into(),
310 message_type: message_type.into(),
311 message_id: format!("{}-{}", sender, message_type),
312 session_id: "s1".into(),
313 sender: sender.into(),
314 timestamp_unix_ms: chrono::Utc::now().timestamp_millis(),
315 payload,
316 }
317 }
318
319 fn commitment_payload() -> Vec<u8> {
320 CommitmentPayload {
321 commitment_id: "c1".into(),
322 action: "handoff.accepted".into(),
323 authority_scope: "support".into(),
324 reason: "accepted".into(),
325 mode_version: "1.0.0".into(),
326 policy_version: "policy".into(),
327 configuration_version: "config".into(),
328 outcome_positive: true,
329 supersedes: None,
330 }
331 .encode_to_vec()
332 }
333
334 fn apply(session: &mut Session, result: ModeResponse) {
335 match result {
336 ModeResponse::PersistState(data) => session.mode_state = data,
337 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
338 _ => {}
339 }
340 }
341
342 fn make_offer(handoff_id: &str, target: &str) -> Vec<u8> {
343 HandoffOfferPayload {
344 handoff_id: handoff_id.into(),
345 target_participant: target.into(),
346 scope: "support".into(),
347 reason: "escalate".into(),
348 }
349 .encode_to_vec()
350 }
351
352 fn make_context(handoff_id: &str) -> Vec<u8> {
353 HandoffContextPayload {
354 handoff_id: handoff_id.into(),
355 content_type: "text/plain".into(),
356 context: b"background info".to_vec(),
357 }
358 .encode_to_vec()
359 }
360
361 fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
362 HandoffAcceptPayload {
363 handoff_id: handoff_id.into(),
364 accepted_by: accepted_by.into(),
365 reason: "ready".into(),
366 }
367 .encode_to_vec()
368 }
369
370 fn make_decline(handoff_id: &str, declined_by: &str) -> Vec<u8> {
371 HandoffDeclinePayload {
372 handoff_id: handoff_id.into(),
373 declined_by: declined_by.into(),
374 reason: "busy".into(),
375 }
376 .encode_to_vec()
377 }
378
379 #[test]
382 fn session_start_initializes_state() {
383 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
384 let session = base_session();
385 let result = mode
386 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
387 .unwrap();
388 match result {
389 ModeResponse::PersistState(data) => {
390 let state: HandoffState = serde_json::from_slice(&data).unwrap();
391 assert!(state.offers.is_empty());
392 }
393 _ => panic!("Expected PersistState"),
394 }
395 }
396
397 #[test]
398 fn session_start_requires_two_participants() {
399 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
400 let mut session = base_session();
401 session.participants = vec!["owner".into()]; let err = mode
403 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
404 .unwrap_err();
405 assert_eq!(err.to_string(), "InvalidPayload");
406 }
407
408 #[test]
409 fn session_start_rejects_when_initiator_not_participant() {
410 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
411 let mut session = base_session();
412 session.participants = vec!["target".into(), "other".into()]; let err = mode
414 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
415 .unwrap_err();
416 assert_eq!(err.to_string(), "InvalidPayload");
417 }
418
419 #[test]
422 fn offer_creates_entry() {
423 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
424 let mut session = base_session();
425 let result = mode
426 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
427 .unwrap();
428 apply(&mut session, result);
429 let result = mode
430 .on_message(
431 &session,
432 &env("owner", "HandoffOffer", make_offer("h1", "target")),
433 )
434 .unwrap();
435 match result {
436 ModeResponse::PersistState(data) => {
437 let state: HandoffState = serde_json::from_slice(&data).unwrap();
438 assert!(state.offers.contains_key("h1"));
439 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered);
440 }
441 _ => panic!("Expected PersistState"),
442 }
443 }
444
445 #[test]
446 fn duplicate_offer_id_rejected() {
447 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
448 let mut session = base_session();
449 let result = mode
450 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
451 .unwrap();
452 apply(&mut session, result);
453 let result = mode
454 .on_message(
455 &session,
456 &env("owner", "HandoffOffer", make_offer("h1", "target")),
457 )
458 .unwrap();
459 apply(&mut session, result);
460 let err = mode
461 .on_message(
462 &session,
463 &env("owner", "HandoffOffer", make_offer("h1", "target")),
464 )
465 .unwrap_err();
466 assert_eq!(err.to_string(), "InvalidPayload");
467 }
468
469 #[test]
470 fn offer_to_self_rejected() {
471 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
472 let mut session = base_session();
473 let result = mode
474 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
475 .unwrap();
476 apply(&mut session, result);
477 let err = mode
478 .on_message(
479 &session,
480 &env("owner", "HandoffOffer", make_offer("h1", "owner")),
481 )
482 .unwrap_err();
483 assert_eq!(err.to_string(), "InvalidPayload");
484 }
485
486 #[test]
487 fn offer_to_non_participant_rejected() {
488 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
489 let mut session = base_session();
490 let result = mode
491 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
492 .unwrap();
493 apply(&mut session, result);
494 let err = mode
495 .on_message(
496 &session,
497 &env("owner", "HandoffOffer", make_offer("h1", "outsider")),
498 )
499 .unwrap_err();
500 assert_eq!(err.to_string(), "InvalidPayload");
501 }
502
503 #[test]
506 fn context_for_existing_offer() {
507 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
508 let mut session = base_session();
509 let result = mode
510 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
511 .unwrap();
512 apply(&mut session, result);
513 let result = mode
514 .on_message(
515 &session,
516 &env("owner", "HandoffOffer", make_offer("h1", "target")),
517 )
518 .unwrap();
519 apply(&mut session, result);
520 let result = mode
521 .on_message(
522 &session,
523 &env("owner", "HandoffContext", make_context("h1")),
524 )
525 .unwrap();
526 match result {
527 ModeResponse::PersistState(data) => {
528 let state: HandoffState = serde_json::from_slice(&data).unwrap();
529 assert_eq!(state.contexts["h1"].len(), 1);
530 assert_eq!(state.contexts["h1"][0].content_type, "text/plain");
531 assert_eq!(state.contexts["h1"][0].sender, "owner");
532 }
533 _ => panic!("Expected PersistState"),
534 }
535 }
536
537 #[test]
538 fn context_from_non_offerer_rejected() {
539 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
540 let mut session = base_session();
541 let result = mode
542 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
543 .unwrap();
544 apply(&mut session, result);
545 let result = mode
546 .on_message(
547 &session,
548 &env("owner", "HandoffOffer", make_offer("h1", "target")),
549 )
550 .unwrap();
551 apply(&mut session, result);
552 let err = mode
553 .on_message(
554 &session,
555 &env("target", "HandoffContext", make_context("h1")),
556 )
557 .unwrap_err();
558 assert_eq!(err.to_string(), "Forbidden");
559 }
560
561 #[test]
564 fn target_can_accept() {
565 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
566 let mut session = base_session();
567 let result = mode
568 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
569 .unwrap();
570 apply(&mut session, result);
571 let result = mode
572 .on_message(
573 &session,
574 &env("owner", "HandoffOffer", make_offer("h1", "target")),
575 )
576 .unwrap();
577 apply(&mut session, result);
578 let result = mode
579 .on_message(
580 &session,
581 &env("target", "HandoffAccept", make_accept("h1", "target")),
582 )
583 .unwrap();
584 match result {
585 ModeResponse::PersistState(data) => {
586 let state: HandoffState = serde_json::from_slice(&data).unwrap();
587 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
588 }
589 _ => panic!("Expected PersistState"),
590 }
591 }
592
593 #[test]
594 fn wrong_target_cannot_accept() {
595 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
596 let mut session = base_session();
597 let result = mode
598 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
599 .unwrap();
600 apply(&mut session, result);
601 let result = mode
602 .on_message(
603 &session,
604 &env("owner", "HandoffOffer", make_offer("h1", "target")),
605 )
606 .unwrap();
607 apply(&mut session, result);
608 let err = mode
609 .on_message(
610 &session,
611 &env("owner", "HandoffAccept", make_accept("h1", "owner")),
612 )
613 .unwrap_err();
614 assert_eq!(err.to_string(), "Forbidden");
615 }
616
617 #[test]
618 fn target_can_decline() {
619 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
620 let mut session = base_session();
621 let result = mode
622 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
623 .unwrap();
624 apply(&mut session, result);
625 let result = mode
626 .on_message(
627 &session,
628 &env("owner", "HandoffOffer", make_offer("h1", "target")),
629 )
630 .unwrap();
631 apply(&mut session, result);
632 let result = mode
633 .on_message(
634 &session,
635 &env("target", "HandoffDecline", make_decline("h1", "target")),
636 )
637 .unwrap();
638 match result {
639 ModeResponse::PersistState(data) => {
640 let state: HandoffState = serde_json::from_slice(&data).unwrap();
641 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined);
642 }
643 _ => panic!("Expected PersistState"),
644 }
645 }
646
647 #[test]
648 fn cannot_accept_already_accepted() {
649 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
650 let mut session = base_session();
651 let result = mode
652 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
653 .unwrap();
654 apply(&mut session, result);
655 let result = mode
656 .on_message(
657 &session,
658 &env("owner", "HandoffOffer", make_offer("h1", "target")),
659 )
660 .unwrap();
661 apply(&mut session, result);
662 let result = mode
663 .on_message(
664 &session,
665 &env("target", "HandoffAccept", make_accept("h1", "target")),
666 )
667 .unwrap();
668 apply(&mut session, result);
669 let err = mode
670 .on_message(
671 &session,
672 &env("target", "HandoffAccept", make_accept("h1", "target")),
673 )
674 .unwrap_err();
675 assert_eq!(err.to_string(), "InvalidPayload");
676 }
677
678 #[test]
681 fn commitment_after_accept() {
682 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
683 let mut session = base_session();
684 let result = mode
685 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
686 .unwrap();
687 apply(&mut session, result);
688 let result = mode
689 .on_message(
690 &session,
691 &env("owner", "HandoffOffer", make_offer("h1", "target")),
692 )
693 .unwrap();
694 apply(&mut session, result);
695 let result = mode
696 .on_message(
697 &session,
698 &env("target", "HandoffAccept", make_accept("h1", "target")),
699 )
700 .unwrap();
701 apply(&mut session, result);
702 let result = mode
703 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
704 .unwrap();
705 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
706 }
707
708 #[test]
709 fn commitment_after_decline() {
710 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
711 let mut session = base_session();
712 let result = mode
713 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
714 .unwrap();
715 apply(&mut session, result);
716 let result = mode
717 .on_message(
718 &session,
719 &env("owner", "HandoffOffer", make_offer("h1", "target")),
720 )
721 .unwrap();
722 apply(&mut session, result);
723 let result = mode
724 .on_message(
725 &session,
726 &env("target", "HandoffDecline", make_decline("h1", "target")),
727 )
728 .unwrap();
729 apply(&mut session, result);
730 let result = mode
731 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
732 .unwrap();
733 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
734 }
735
736 #[test]
737 fn commitment_without_response_rejected() {
738 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
739 let mut session = base_session();
740 let result = mode
741 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
742 .unwrap();
743 apply(&mut session, result);
744 let result = mode
745 .on_message(
746 &session,
747 &env("owner", "HandoffOffer", make_offer("h1", "target")),
748 )
749 .unwrap();
750 apply(&mut session, result);
751 let err = mode
752 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
753 .unwrap_err();
754 assert_eq!(err.to_string(), "InvalidPayload");
755 }
756
757 #[test]
758 fn commitment_with_no_offers_rejected() {
759 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
760 let mut session = base_session();
761 let result = mode
762 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
763 .unwrap();
764 apply(&mut session, result);
765 let err = mode
766 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
767 .unwrap_err();
768 assert_eq!(err.to_string(), "InvalidPayload");
769 }
770
771 #[test]
774 fn full_handoff_lifecycle() {
775 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
776 let mut session = base_session();
777 let result = mode
778 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
779 .unwrap();
780 apply(&mut session, result);
781 let result = mode
782 .on_message(
783 &session,
784 &env("owner", "HandoffOffer", make_offer("h1", "target")),
785 )
786 .unwrap();
787 apply(&mut session, result);
788 let result = mode
789 .on_message(
790 &session,
791 &env("owner", "HandoffContext", make_context("h1")),
792 )
793 .unwrap();
794 apply(&mut session, result);
795 let result = mode
796 .on_message(
797 &session,
798 &env("target", "HandoffAccept", make_accept("h1", "target")),
799 )
800 .unwrap();
801 apply(&mut session, result);
802 let result = mode
803 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
804 .unwrap();
805 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
806 }
807
808 #[test]
811 fn second_offer_while_first_pending_rejected() {
812 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
813 let mut session = base_session();
814 session.participants = vec!["owner".into(), "target".into(), "other".into()];
815 let result = mode
816 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
817 .unwrap();
818 apply(&mut session, result);
819 let result = mode
820 .on_message(
821 &session,
822 &env("owner", "HandoffOffer", make_offer("h1", "target")),
823 )
824 .unwrap();
825 apply(&mut session, result);
826 let err = mode
827 .on_message(
828 &session,
829 &env("owner", "HandoffOffer", make_offer("h2", "other")),
830 )
831 .unwrap_err();
832 assert_eq!(err.to_string(), "InvalidPayload");
833 }
834
835 #[test]
836 fn second_offer_after_first_accepted_is_rejected() {
837 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
839 let mut session = base_session();
840 session.participants = vec!["owner".into(), "target".into(), "other".into()];
841 let result = mode
842 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
843 .unwrap();
844 apply(&mut session, result);
845 let result = mode
846 .on_message(
847 &session,
848 &env("owner", "HandoffOffer", make_offer("h1", "target")),
849 )
850 .unwrap();
851 apply(&mut session, result);
852 let result = mode
853 .on_message(
854 &session,
855 &env("target", "HandoffAccept", make_accept("h1", "target")),
856 )
857 .unwrap();
858 apply(&mut session, result);
859 let err = mode
860 .on_message(
861 &session,
862 &env("owner", "HandoffOffer", make_offer("h2", "other")),
863 )
864 .unwrap_err();
865 assert_eq!(err.to_string(), "InvalidPayload");
866 }
867
868 #[test]
869 fn second_offer_after_first_declined_succeeds() {
870 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
871 let mut session = base_session();
872 session.participants = vec!["owner".into(), "target".into(), "other".into()];
873 let result = mode
874 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
875 .unwrap();
876 apply(&mut session, result);
877 let result = mode
878 .on_message(
879 &session,
880 &env("owner", "HandoffOffer", make_offer("h1", "target")),
881 )
882 .unwrap();
883 apply(&mut session, result);
884 let result = mode
885 .on_message(
886 &session,
887 &env("target", "HandoffDecline", make_decline("h1", "target")),
888 )
889 .unwrap();
890 apply(&mut session, result);
891 mode.on_message(
892 &session,
893 &env("owner", "HandoffOffer", make_offer("h2", "other")),
894 )
895 .unwrap();
896 }
897
898 #[test]
901 fn commitment_version_mismatch_rejected() {
902 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
903 let mut session = base_session();
904 let result = mode
905 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
906 .unwrap();
907 apply(&mut session, result);
908 let result = mode
909 .on_message(
910 &session,
911 &env("owner", "HandoffOffer", make_offer("h1", "target")),
912 )
913 .unwrap();
914 apply(&mut session, result);
915 let result = mode
916 .on_message(
917 &session,
918 &env("target", "HandoffAccept", make_accept("h1", "target")),
919 )
920 .unwrap();
921 apply(&mut session, result);
922 let bad_commitment = CommitmentPayload {
923 commitment_id: "c1".into(),
924 action: "handoff.accepted".into(),
925 authority_scope: "support".into(),
926 reason: "accepted".into(),
927 mode_version: "wrong".into(),
928 policy_version: "policy".into(),
929 configuration_version: "config".into(),
930 outcome_positive: true,
931 supersedes: None,
932 }
933 .encode_to_vec();
934 let err = mode
935 .on_message(&session, &env("owner", "Commitment", bad_commitment))
936 .unwrap_err();
937 assert_eq!(err.to_string(), "InvalidPayload");
938 }
939
940 #[test]
943 fn unknown_message_type_rejected() {
944 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
945 let mut session = base_session();
946 let result = mode
947 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
948 .unwrap();
949 apply(&mut session, result);
950 let err = mode
951 .on_message(&session, &env("owner", "CustomType", vec![]))
952 .unwrap_err();
953 assert_eq!(err.to_string(), "InvalidPayload");
954 }
955
956 #[test]
957 fn context_after_accept_is_permitted() {
958 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
961 let mut session = base_session();
962 let resp = mode
963 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
964 .unwrap();
965 apply(&mut session, resp);
966 let resp = mode
967 .on_message(
968 &session,
969 &env("owner", "HandoffOffer", make_offer("h1", "target")),
970 )
971 .unwrap();
972 apply(&mut session, resp);
973 let resp = mode
974 .on_message(
975 &session,
976 &env("target", "HandoffAccept", make_accept("h1", "target")),
977 )
978 .unwrap();
979 apply(&mut session, resp);
980 let result = mode.on_message(
982 &session,
983 &env("owner", "HandoffContext", make_context("h1")),
984 );
985 assert!(
986 result.is_ok(),
987 "late HandoffContext should be permitted per RFC"
988 );
989 }
990
991 #[test]
994 fn handoff_policy_evaluator_always_allows() {
995 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
996 let mut session = base_session();
997 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
998 policy_id: "test-handoff".into(),
999 mode: "macp.mode.handoff.v1".into(),
1000 description: "handoff policy".into(),
1001 rules: serde_json::json!({
1002 "acceptance": { "implicit_accept_timeout_ms": 0 },
1003 "commitment": { "authority": "initiator_only" }
1004 }),
1005 schema_version: 1,
1006 });
1007 let result = mode
1008 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1009 .unwrap();
1010 apply(&mut session, result);
1011 let result = mode
1012 .on_message(
1013 &session,
1014 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1015 )
1016 .unwrap();
1017 apply(&mut session, result);
1018 let result = mode
1019 .on_message(
1020 &session,
1021 &env("target", "HandoffAccept", make_accept("h1", "target")),
1022 )
1023 .unwrap();
1024 apply(&mut session, result);
1025 let result = mode
1027 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1028 .unwrap();
1029 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1030 }
1031
1032 #[test]
1035 fn second_offer_to_different_target_while_first_pending_rejected() {
1036 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1037 let mut session = base_session();
1038 session.participants = vec!["owner".into(), "targetA".into(), "targetB".into()];
1039 let result = mode
1040 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1041 .unwrap();
1042 apply(&mut session, result);
1043 let result = mode
1045 .on_message(
1046 &session,
1047 &env("owner", "HandoffOffer", make_offer("h1", "targetA")),
1048 )
1049 .unwrap();
1050 apply(&mut session, result);
1051 let err = mode
1053 .on_message(
1054 &session,
1055 &env("owner", "HandoffOffer", make_offer("h2", "targetB")),
1056 )
1057 .unwrap_err();
1058 assert_eq!(err.to_string(), "InvalidPayload");
1059 }
1060
1061 #[test]
1064 fn offer_after_accept_blocked_per_rfc() {
1065 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1068 let mut session = base_session();
1069 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1070 let result = mode
1071 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1072 .unwrap();
1073 apply(&mut session, result);
1074 let result = mode
1075 .on_message(
1076 &session,
1077 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1078 )
1079 .unwrap();
1080 apply(&mut session, result);
1081 let result = mode
1082 .on_message(
1083 &session,
1084 &env("target", "HandoffAccept", make_accept("h1", "target")),
1085 )
1086 .unwrap();
1087 apply(&mut session, result);
1088 let err = mode
1090 .on_message(
1091 &session,
1092 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1093 )
1094 .unwrap_err();
1095 assert_eq!(err.to_string(), "InvalidPayload");
1096 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1097 assert_eq!(state.offers.len(), 1);
1098 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1099 }
1100
1101 #[test]
1102 fn offered_at_ms_is_populated() {
1103 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1104 let mut session = base_session();
1105 session.participants = vec!["owner".into(), "target".into()];
1106 let result = mode
1107 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1108 .unwrap();
1109 apply(&mut session, result);
1110 let result = mode
1111 .on_message(
1112 &session,
1113 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1114 )
1115 .unwrap();
1116 apply(&mut session, result);
1117 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1118 assert!(
1119 state.offers["h1"].offered_at_ms > 0,
1120 "offered_at_ms should be set"
1121 );
1122 }
1123
1124 #[test]
1125 fn implicit_accept_timeout_fires() {
1126 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1129 let mut session = base_session();
1130 session.participants = vec!["owner".into(), "target".into()];
1131 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1132 policy_id: "auto-accept".into(),
1133 mode: "macp.mode.handoff.v1".into(),
1134 description: "short timeout".into(),
1135 rules: serde_json::json!({
1136 "acceptance": { "implicit_accept_timeout_ms": 100 },
1137 "commitment": { "authority": "initiator_only" }
1138 }),
1139 schema_version: 1,
1140 });
1141 let result = mode
1142 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1143 .unwrap();
1144 apply(&mut session, result);
1145 let offer_time = 1000i64;
1147 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1148 offer_env.timestamp_unix_ms = offer_time;
1149 let result = mode.on_message(&session, &offer_env).unwrap();
1150 apply(&mut session, result);
1151 let mut commit_env = env("owner", "Commitment", commitment_payload());
1153 commit_env.timestamp_unix_ms = offer_time + 200; let commit = mode.on_message(&session, &commit_env).unwrap();
1155 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1156 }
1157}