1use crate::mode::util::{
2 check_commitment_authority, enforce_commitment_policy, is_declared_participant,
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::handoff_pb::{
9 HandoffAcceptPayload, HandoffContextPayload, HandoffDeclinePayload, HandoffOfferPayload,
10};
11use macp_pb::pb::Envelope;
12use prost::Message;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub enum HandoffDisposition {
18 Offered,
19 Accepted,
20 Declined,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct HandoffOfferRecord {
25 pub handoff_id: String,
26 pub target_participant: String,
27 pub scope: String,
28 pub reason: String,
29 pub offered_by: String,
30 pub disposition: HandoffDisposition,
31 pub accepted_by: Option<String>,
32 pub declined_by: Option<String>,
33 pub outcome_reason: Option<String>,
34 #[serde(default)]
35 pub offered_at_ms: i64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct HandoffContextRecord {
40 pub content_type: String,
41 pub context: Vec<u8>,
42 pub sender: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46pub struct HandoffState {
47 pub offers: BTreeMap<String, HandoffOfferRecord>,
48 pub contexts: BTreeMap<String, Vec<HandoffContextRecord>>,
49}
50
51pub struct HandoffMode {
52 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
53}
54
55impl HandoffMode {
56 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
58 Self { evaluator }
59 }
60
61 fn encode_state(state: &HandoffState) -> Vec<u8> {
62 crate::mode::util::encode_mode_state(state)
63 }
64
65 fn decode_state(data: &[u8]) -> Result<HandoffState, MacpError> {
66 crate::mode::util::decode_mode_state(data)
67 }
68
69 fn commitment_ready(state: &HandoffState) -> bool {
70 state.offers.values().any(|offer| {
71 offer.disposition == HandoffDisposition::Accepted
72 || offer.disposition == HandoffDisposition::Declined
73 })
74 }
75}
76
77impl Mode for HandoffMode {
78 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
79 match env.message_type.as_str() {
80 "Commitment" => check_commitment_authority(session, &env.sender),
81 "HandoffOffer" if env.sender == session.initiator_sender => Ok(()),
83 "HandoffOffer" => Err(MacpError::Forbidden),
84 _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
86 _ => Err(MacpError::Forbidden),
87 }
88 }
89
90 fn on_session_start(
91 &self,
92 session: &Session,
93 _env: &Envelope,
94 ) -> Result<ModeResponse, MacpError> {
95 if session.participants.len() < 2 {
103 return Err(MacpError::InvalidPayload);
104 }
105 if !session
106 .participants
107 .iter()
108 .any(|p| p == &session.initiator_sender)
109 {
110 return Err(MacpError::InvalidPayload);
111 }
112 Ok(ModeResponse::PersistState(Self::encode_state(
113 &HandoffState::default(),
114 )))
115 }
116
117 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
118 self.handle_message(session, env, env.timestamp_unix_ms)
123 }
124
125 fn on_message_at(
126 &self,
127 session: &Session,
128 env: &Envelope,
129 ctx: &macp_core::mode::MessageContext,
130 ) -> Result<ModeResponse, MacpError> {
131 let clock_ms = if session.semantics_rev >= 1 {
138 ctx.accepted_at_ms
139 } else {
140 env.timestamp_unix_ms
141 };
142 self.handle_message(session, env, clock_ms)
143 }
144}
145
146impl HandoffMode {
147 fn handle_message(
148 &self,
149 session: &Session,
150 env: &Envelope,
151 clock_ms: i64,
152 ) -> Result<ModeResponse, MacpError> {
153 let mut state = if session.mode_state.is_empty() {
154 HandoffState::default()
155 } else {
156 Self::decode_state(&session.mode_state)?
157 };
158
159 match env.message_type.as_str() {
160 "HandoffOffer" => {
161 let payload = HandoffOfferPayload::decode(&*env.payload)
162 .map_err(|_| MacpError::InvalidPayload)?;
163 if payload.handoff_id.is_empty()
166 || payload.target_participant.is_empty()
167 || state.offers.contains_key(&payload.handoff_id)
168 || !is_declared_participant(&session.participants, &payload.target_participant)
169 || payload.target_participant == env.sender
170 || state
171 .offers
172 .values()
173 .any(|o| o.disposition == HandoffDisposition::Offered)
174 || state
175 .offers
176 .values()
177 .any(|o| o.disposition == HandoffDisposition::Accepted)
178 {
179 return Err(MacpError::InvalidPayload);
180 }
181 state.offers.insert(
182 payload.handoff_id.clone(),
183 HandoffOfferRecord {
184 handoff_id: payload.handoff_id,
185 target_participant: payload.target_participant,
186 scope: payload.scope,
187 reason: payload.reason,
188 offered_by: env.sender.clone(),
189 disposition: HandoffDisposition::Offered,
190 accepted_by: None,
191 declined_by: None,
192 outcome_reason: None,
193 offered_at_ms: if session.semantics_rev >= 1 {
202 clock_ms
203 } else {
204 env.timestamp_unix_ms
205 },
206 },
207 );
208 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
209 }
210 "HandoffContext" => {
211 let payload = HandoffContextPayload::decode(&*env.payload)
212 .map_err(|_| MacpError::InvalidPayload)?;
213 let offer = state
214 .offers
215 .get(&payload.handoff_id)
216 .ok_or(MacpError::InvalidPayload)?;
217 if offer.offered_by != env.sender {
218 return Err(MacpError::Forbidden);
219 }
220 state
223 .contexts
224 .entry(payload.handoff_id)
225 .or_default()
226 .push(HandoffContextRecord {
227 content_type: payload.content_type,
228 context: payload.context,
229 sender: env.sender.clone(),
230 });
231 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
232 }
233 "HandoffAccept" => {
234 let payload = HandoffAcceptPayload::decode(&*env.payload)
235 .map_err(|_| MacpError::InvalidPayload)?;
236 if payload.implicit {
241 return Err(MacpError::InvalidPayload);
242 }
243 let offer = state
244 .offers
245 .get_mut(&payload.handoff_id)
246 .ok_or(MacpError::InvalidPayload)?;
247 if offer.target_participant != env.sender {
248 return Err(MacpError::Forbidden);
249 }
250 if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender {
251 return Err(MacpError::InvalidPayload);
252 }
253 if offer.disposition != HandoffDisposition::Offered {
254 return Err(MacpError::InvalidPayload);
255 }
256 offer.disposition = HandoffDisposition::Accepted;
257 offer.accepted_by = Some(env.sender.clone());
258 offer.outcome_reason = Some(payload.reason);
259 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
260 }
261 "HandoffDecline" => {
262 let payload = HandoffDeclinePayload::decode(&*env.payload)
263 .map_err(|_| MacpError::InvalidPayload)?;
264 let offer = state
265 .offers
266 .get_mut(&payload.handoff_id)
267 .ok_or(MacpError::InvalidPayload)?;
268 if offer.target_participant != env.sender {
269 return Err(MacpError::Forbidden);
270 }
271 if !payload.declined_by.is_empty() && payload.declined_by != env.sender {
272 return Err(MacpError::InvalidPayload);
273 }
274 if offer.disposition != HandoffDisposition::Offered {
275 return Err(MacpError::InvalidPayload);
276 }
277 offer.disposition = HandoffDisposition::Declined;
278 offer.declined_by = Some(env.sender.clone());
279 offer.outcome_reason = Some(payload.reason);
280 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
281 }
282 "Commitment" => {
283 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
284 if let Some(ref policy) = session.policy_definition {
286 let rules: macp_core::policy::rules::HandoffPolicyRules =
287 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
288 if rules.acceptance.implicit_accept_timeout_ms > 0 {
289 let now_ms = clock_ms;
294 let timeout = rules.acceptance.implicit_accept_timeout_ms as i64;
295 for offer in state.offers.values_mut() {
296 if offer.disposition == HandoffDisposition::Offered
297 && offer.offered_at_ms > 0
298 && (now_ms - offer.offered_at_ms) >= timeout
299 {
300 offer.disposition = HandoffDisposition::Accepted;
301 offer.accepted_by = Some(offer.target_participant.clone());
302 offer.outcome_reason = Some("implicit accept (timeout)".into());
303 }
304 }
305 }
306 }
307 if !Self::commitment_ready(&state) {
308 return Err(MacpError::InvalidPayload);
309 }
310 enforce_commitment_policy(
313 session,
314 macp_core::policy::CommitmentMode::Handoff,
315 commitment.outcome_positive,
316 &*self.evaluator,
317 )?;
318 Ok(ModeResponse::PersistAndResolve {
319 state: Self::encode_state(&state),
320 resolution: env.payload.clone(),
321 })
322 }
323 _ => Err(MacpError::InvalidPayload),
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use macp_core::session::Session;
332 use macp_pb::pb::CommitmentPayload;
333
334 fn base_session() -> Session {
335 Session::builder("s1", "macp.mode.handoff.v1", "owner")
336 .ttl_ms(60_000)
337 .participants(vec!["owner".into(), "target".into()])
338 .mode_version("1.0.0")
339 .configuration_version("config")
340 .policy_version("policy")
341 .build()
342 }
343
344 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
345 Envelope {
346 macp_version: "1.0".into(),
347 mode: "macp.mode.handoff.v1".into(),
348 message_type: message_type.into(),
349 message_id: format!("{}-{}", sender, message_type),
350 session_id: "s1".into(),
351 sender: sender.into(),
352 timestamp_unix_ms: chrono::Utc::now().timestamp_millis(),
353 payload,
354 }
355 }
356
357 fn commitment_payload() -> Vec<u8> {
358 CommitmentPayload {
359 commitment_id: "c1".into(),
360 action: "handoff.accepted".into(),
361 authority_scope: "support".into(),
362 reason: "accepted".into(),
363 mode_version: "1.0.0".into(),
364 policy_version: "policy".into(),
365 configuration_version: "config".into(),
366 outcome_positive: true,
367 supersedes: None,
368 }
369 .encode_to_vec()
370 }
371
372 fn apply(session: &mut Session, result: ModeResponse) {
373 match result {
374 ModeResponse::PersistState(data) => session.mode_state = data,
375 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
376 _ => {}
377 }
378 }
379
380 fn make_offer(handoff_id: &str, target: &str) -> Vec<u8> {
381 HandoffOfferPayload {
382 handoff_id: handoff_id.into(),
383 target_participant: target.into(),
384 scope: "support".into(),
385 reason: "escalate".into(),
386 }
387 .encode_to_vec()
388 }
389
390 fn make_context(handoff_id: &str) -> Vec<u8> {
391 HandoffContextPayload {
392 handoff_id: handoff_id.into(),
393 content_type: "text/plain".into(),
394 context: b"background info".to_vec(),
395 }
396 .encode_to_vec()
397 }
398
399 fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
400 HandoffAcceptPayload {
401 handoff_id: handoff_id.into(),
402 accepted_by: accepted_by.into(),
403 reason: "ready".into(),
404 implicit: false,
405 }
406 .encode_to_vec()
407 }
408
409 fn make_decline(handoff_id: &str, declined_by: &str) -> Vec<u8> {
410 HandoffDeclinePayload {
411 handoff_id: handoff_id.into(),
412 declined_by: declined_by.into(),
413 reason: "busy".into(),
414 }
415 .encode_to_vec()
416 }
417
418 #[test]
421 fn session_start_initializes_state() {
422 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
423 let session = base_session();
424 let result = mode
425 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
426 .unwrap();
427 match result {
428 ModeResponse::PersistState(data) => {
429 let state: HandoffState = serde_json::from_slice(&data).unwrap();
430 assert!(state.offers.is_empty());
431 }
432 _ => panic!("Expected PersistState"),
433 }
434 }
435
436 #[test]
437 fn session_start_requires_two_participants() {
438 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
439 let mut session = base_session();
440 session.participants = vec!["owner".into()]; let err = mode
442 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
443 .unwrap_err();
444 assert_eq!(err.to_string(), "InvalidPayload");
445 }
446
447 #[test]
448 fn session_start_rejects_when_initiator_not_participant() {
449 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
450 let mut session = base_session();
451 session.participants = vec!["target".into(), "other".into()]; let err = mode
453 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
454 .unwrap_err();
455 assert_eq!(err.to_string(), "InvalidPayload");
456 }
457
458 #[test]
461 fn offer_creates_entry() {
462 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
463 let mut session = base_session();
464 let result = mode
465 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
466 .unwrap();
467 apply(&mut session, result);
468 let result = mode
469 .on_message(
470 &session,
471 &env("owner", "HandoffOffer", make_offer("h1", "target")),
472 )
473 .unwrap();
474 match result {
475 ModeResponse::PersistState(data) => {
476 let state: HandoffState = serde_json::from_slice(&data).unwrap();
477 assert!(state.offers.contains_key("h1"));
478 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered);
479 }
480 _ => panic!("Expected PersistState"),
481 }
482 }
483
484 #[test]
485 fn duplicate_offer_id_rejected() {
486 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
487 let mut session = base_session();
488 let result = mode
489 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
490 .unwrap();
491 apply(&mut session, result);
492 let result = mode
493 .on_message(
494 &session,
495 &env("owner", "HandoffOffer", make_offer("h1", "target")),
496 )
497 .unwrap();
498 apply(&mut session, result);
499 let err = mode
500 .on_message(
501 &session,
502 &env("owner", "HandoffOffer", make_offer("h1", "target")),
503 )
504 .unwrap_err();
505 assert_eq!(err.to_string(), "InvalidPayload");
506 }
507
508 #[test]
509 fn offer_to_self_rejected() {
510 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
511 let mut session = base_session();
512 let result = mode
513 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
514 .unwrap();
515 apply(&mut session, result);
516 let err = mode
517 .on_message(
518 &session,
519 &env("owner", "HandoffOffer", make_offer("h1", "owner")),
520 )
521 .unwrap_err();
522 assert_eq!(err.to_string(), "InvalidPayload");
523 }
524
525 #[test]
526 fn offer_to_non_participant_rejected() {
527 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
528 let mut session = base_session();
529 let result = mode
530 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
531 .unwrap();
532 apply(&mut session, result);
533 let err = mode
534 .on_message(
535 &session,
536 &env("owner", "HandoffOffer", make_offer("h1", "outsider")),
537 )
538 .unwrap_err();
539 assert_eq!(err.to_string(), "InvalidPayload");
540 }
541
542 #[test]
545 fn context_for_existing_offer() {
546 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
547 let mut session = base_session();
548 let result = mode
549 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
550 .unwrap();
551 apply(&mut session, result);
552 let result = mode
553 .on_message(
554 &session,
555 &env("owner", "HandoffOffer", make_offer("h1", "target")),
556 )
557 .unwrap();
558 apply(&mut session, result);
559 let result = mode
560 .on_message(
561 &session,
562 &env("owner", "HandoffContext", make_context("h1")),
563 )
564 .unwrap();
565 match result {
566 ModeResponse::PersistState(data) => {
567 let state: HandoffState = serde_json::from_slice(&data).unwrap();
568 assert_eq!(state.contexts["h1"].len(), 1);
569 assert_eq!(state.contexts["h1"][0].content_type, "text/plain");
570 assert_eq!(state.contexts["h1"][0].sender, "owner");
571 }
572 _ => panic!("Expected PersistState"),
573 }
574 }
575
576 #[test]
577 fn context_from_non_offerer_rejected() {
578 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
579 let mut session = base_session();
580 let result = mode
581 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
582 .unwrap();
583 apply(&mut session, result);
584 let result = mode
585 .on_message(
586 &session,
587 &env("owner", "HandoffOffer", make_offer("h1", "target")),
588 )
589 .unwrap();
590 apply(&mut session, result);
591 let err = mode
592 .on_message(
593 &session,
594 &env("target", "HandoffContext", make_context("h1")),
595 )
596 .unwrap_err();
597 assert_eq!(err.to_string(), "Forbidden");
598 }
599
600 #[test]
603 fn target_can_accept() {
604 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
605 let mut session = base_session();
606 let result = mode
607 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
608 .unwrap();
609 apply(&mut session, result);
610 let result = mode
611 .on_message(
612 &session,
613 &env("owner", "HandoffOffer", make_offer("h1", "target")),
614 )
615 .unwrap();
616 apply(&mut session, result);
617 let result = mode
618 .on_message(
619 &session,
620 &env("target", "HandoffAccept", make_accept("h1", "target")),
621 )
622 .unwrap();
623 match result {
624 ModeResponse::PersistState(data) => {
625 let state: HandoffState = serde_json::from_slice(&data).unwrap();
626 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
627 }
628 _ => panic!("Expected PersistState"),
629 }
630 }
631
632 #[test]
633 fn client_submitted_implicit_accept_is_rejected() {
634 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
637 let mut session = base_session();
638 let result = mode
639 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
640 .unwrap();
641 apply(&mut session, result);
642 let result = mode
643 .on_message(
644 &session,
645 &env("owner", "HandoffOffer", make_offer("h1", "target")),
646 )
647 .unwrap();
648 apply(&mut session, result);
649 let forged_accept = HandoffAcceptPayload {
650 handoff_id: "h1".into(),
651 accepted_by: "target".into(),
652 reason: "ready".into(),
653 implicit: true,
654 }
655 .encode_to_vec();
656 let err = mode
657 .on_message(&session, &env("target", "HandoffAccept", forged_accept))
658 .unwrap_err();
659 assert_eq!(err.to_string(), "InvalidPayload");
660 }
661
662 #[test]
663 fn wrong_target_cannot_accept() {
664 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
665 let mut session = base_session();
666 let result = mode
667 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
668 .unwrap();
669 apply(&mut session, result);
670 let result = mode
671 .on_message(
672 &session,
673 &env("owner", "HandoffOffer", make_offer("h1", "target")),
674 )
675 .unwrap();
676 apply(&mut session, result);
677 let err = mode
678 .on_message(
679 &session,
680 &env("owner", "HandoffAccept", make_accept("h1", "owner")),
681 )
682 .unwrap_err();
683 assert_eq!(err.to_string(), "Forbidden");
684 }
685
686 #[test]
687 fn target_can_decline() {
688 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
689 let mut session = base_session();
690 let result = mode
691 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
692 .unwrap();
693 apply(&mut session, result);
694 let result = mode
695 .on_message(
696 &session,
697 &env("owner", "HandoffOffer", make_offer("h1", "target")),
698 )
699 .unwrap();
700 apply(&mut session, result);
701 let result = mode
702 .on_message(
703 &session,
704 &env("target", "HandoffDecline", make_decline("h1", "target")),
705 )
706 .unwrap();
707 match result {
708 ModeResponse::PersistState(data) => {
709 let state: HandoffState = serde_json::from_slice(&data).unwrap();
710 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined);
711 }
712 _ => panic!("Expected PersistState"),
713 }
714 }
715
716 #[test]
717 fn cannot_accept_already_accepted() {
718 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
719 let mut session = base_session();
720 let result = mode
721 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
722 .unwrap();
723 apply(&mut session, result);
724 let result = mode
725 .on_message(
726 &session,
727 &env("owner", "HandoffOffer", make_offer("h1", "target")),
728 )
729 .unwrap();
730 apply(&mut session, result);
731 let result = mode
732 .on_message(
733 &session,
734 &env("target", "HandoffAccept", make_accept("h1", "target")),
735 )
736 .unwrap();
737 apply(&mut session, result);
738 let err = mode
739 .on_message(
740 &session,
741 &env("target", "HandoffAccept", make_accept("h1", "target")),
742 )
743 .unwrap_err();
744 assert_eq!(err.to_string(), "InvalidPayload");
745 }
746
747 #[test]
750 fn commitment_after_accept() {
751 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
752 let mut session = base_session();
753 let result = mode
754 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
755 .unwrap();
756 apply(&mut session, result);
757 let result = mode
758 .on_message(
759 &session,
760 &env("owner", "HandoffOffer", make_offer("h1", "target")),
761 )
762 .unwrap();
763 apply(&mut session, result);
764 let result = mode
765 .on_message(
766 &session,
767 &env("target", "HandoffAccept", make_accept("h1", "target")),
768 )
769 .unwrap();
770 apply(&mut session, result);
771 let result = mode
772 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
773 .unwrap();
774 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
775 }
776
777 #[test]
778 fn commitment_after_decline() {
779 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
780 let mut session = base_session();
781 let result = mode
782 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
783 .unwrap();
784 apply(&mut session, result);
785 let result = mode
786 .on_message(
787 &session,
788 &env("owner", "HandoffOffer", make_offer("h1", "target")),
789 )
790 .unwrap();
791 apply(&mut session, result);
792 let result = mode
793 .on_message(
794 &session,
795 &env("target", "HandoffDecline", make_decline("h1", "target")),
796 )
797 .unwrap();
798 apply(&mut session, result);
799 let result = mode
800 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
801 .unwrap();
802 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
803 }
804
805 #[test]
806 fn commitment_without_response_rejected() {
807 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
808 let mut session = base_session();
809 let result = mode
810 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
811 .unwrap();
812 apply(&mut session, result);
813 let result = mode
814 .on_message(
815 &session,
816 &env("owner", "HandoffOffer", make_offer("h1", "target")),
817 )
818 .unwrap();
819 apply(&mut session, result);
820 let err = mode
821 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
822 .unwrap_err();
823 assert_eq!(err.to_string(), "InvalidPayload");
824 }
825
826 #[test]
827 fn commitment_with_no_offers_rejected() {
828 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
829 let mut session = base_session();
830 let result = mode
831 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
832 .unwrap();
833 apply(&mut session, result);
834 let err = mode
835 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
836 .unwrap_err();
837 assert_eq!(err.to_string(), "InvalidPayload");
838 }
839
840 #[test]
843 fn full_handoff_lifecycle() {
844 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
845 let mut session = base_session();
846 let result = mode
847 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
848 .unwrap();
849 apply(&mut session, result);
850 let result = mode
851 .on_message(
852 &session,
853 &env("owner", "HandoffOffer", make_offer("h1", "target")),
854 )
855 .unwrap();
856 apply(&mut session, result);
857 let result = mode
858 .on_message(
859 &session,
860 &env("owner", "HandoffContext", make_context("h1")),
861 )
862 .unwrap();
863 apply(&mut session, result);
864 let result = mode
865 .on_message(
866 &session,
867 &env("target", "HandoffAccept", make_accept("h1", "target")),
868 )
869 .unwrap();
870 apply(&mut session, result);
871 let result = mode
872 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
873 .unwrap();
874 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
875 }
876
877 #[test]
880 fn second_offer_while_first_pending_rejected() {
881 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
882 let mut session = base_session();
883 session.participants = vec!["owner".into(), "target".into(), "other".into()];
884 let result = mode
885 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
886 .unwrap();
887 apply(&mut session, result);
888 let result = mode
889 .on_message(
890 &session,
891 &env("owner", "HandoffOffer", make_offer("h1", "target")),
892 )
893 .unwrap();
894 apply(&mut session, result);
895 let err = mode
896 .on_message(
897 &session,
898 &env("owner", "HandoffOffer", make_offer("h2", "other")),
899 )
900 .unwrap_err();
901 assert_eq!(err.to_string(), "InvalidPayload");
902 }
903
904 #[test]
905 fn second_offer_after_first_accepted_is_rejected() {
906 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
908 let mut session = base_session();
909 session.participants = vec!["owner".into(), "target".into(), "other".into()];
910 let result = mode
911 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
912 .unwrap();
913 apply(&mut session, result);
914 let result = mode
915 .on_message(
916 &session,
917 &env("owner", "HandoffOffer", make_offer("h1", "target")),
918 )
919 .unwrap();
920 apply(&mut session, result);
921 let result = mode
922 .on_message(
923 &session,
924 &env("target", "HandoffAccept", make_accept("h1", "target")),
925 )
926 .unwrap();
927 apply(&mut session, result);
928 let err = mode
929 .on_message(
930 &session,
931 &env("owner", "HandoffOffer", make_offer("h2", "other")),
932 )
933 .unwrap_err();
934 assert_eq!(err.to_string(), "InvalidPayload");
935 }
936
937 #[test]
938 fn second_offer_after_first_declined_succeeds() {
939 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
940 let mut session = base_session();
941 session.participants = vec!["owner".into(), "target".into(), "other".into()];
942 let result = mode
943 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
944 .unwrap();
945 apply(&mut session, result);
946 let result = mode
947 .on_message(
948 &session,
949 &env("owner", "HandoffOffer", make_offer("h1", "target")),
950 )
951 .unwrap();
952 apply(&mut session, result);
953 let result = mode
954 .on_message(
955 &session,
956 &env("target", "HandoffDecline", make_decline("h1", "target")),
957 )
958 .unwrap();
959 apply(&mut session, result);
960 mode.on_message(
961 &session,
962 &env("owner", "HandoffOffer", make_offer("h2", "other")),
963 )
964 .unwrap();
965 }
966
967 #[test]
970 fn commitment_version_mismatch_rejected() {
971 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
972 let mut session = base_session();
973 let result = mode
974 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
975 .unwrap();
976 apply(&mut session, result);
977 let result = mode
978 .on_message(
979 &session,
980 &env("owner", "HandoffOffer", make_offer("h1", "target")),
981 )
982 .unwrap();
983 apply(&mut session, result);
984 let result = mode
985 .on_message(
986 &session,
987 &env("target", "HandoffAccept", make_accept("h1", "target")),
988 )
989 .unwrap();
990 apply(&mut session, result);
991 let bad_commitment = CommitmentPayload {
992 commitment_id: "c1".into(),
993 action: "handoff.accepted".into(),
994 authority_scope: "support".into(),
995 reason: "accepted".into(),
996 mode_version: "wrong".into(),
997 policy_version: "policy".into(),
998 configuration_version: "config".into(),
999 outcome_positive: true,
1000 supersedes: None,
1001 }
1002 .encode_to_vec();
1003 let err = mode
1004 .on_message(&session, &env("owner", "Commitment", bad_commitment))
1005 .unwrap_err();
1006 assert_eq!(err.to_string(), "InvalidPayload");
1007 }
1008
1009 #[test]
1012 fn unknown_message_type_rejected() {
1013 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1014 let mut session = base_session();
1015 let result = mode
1016 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1017 .unwrap();
1018 apply(&mut session, result);
1019 let err = mode
1020 .on_message(&session, &env("owner", "CustomType", vec![]))
1021 .unwrap_err();
1022 assert_eq!(err.to_string(), "InvalidPayload");
1023 }
1024
1025 #[test]
1026 fn context_after_accept_is_permitted() {
1027 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1030 let mut session = base_session();
1031 let resp = mode
1032 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1033 .unwrap();
1034 apply(&mut session, resp);
1035 let resp = mode
1036 .on_message(
1037 &session,
1038 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1039 )
1040 .unwrap();
1041 apply(&mut session, resp);
1042 let resp = mode
1043 .on_message(
1044 &session,
1045 &env("target", "HandoffAccept", make_accept("h1", "target")),
1046 )
1047 .unwrap();
1048 apply(&mut session, resp);
1049 let result = mode.on_message(
1051 &session,
1052 &env("owner", "HandoffContext", make_context("h1")),
1053 );
1054 assert!(
1055 result.is_ok(),
1056 "late HandoffContext should be permitted per RFC"
1057 );
1058 }
1059
1060 #[test]
1063 fn handoff_policy_evaluator_always_allows() {
1064 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1065 let mut session = base_session();
1066 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1067 policy_id: "test-handoff".into(),
1068 mode: "macp.mode.handoff.v1".into(),
1069 description: "handoff policy".into(),
1070 rules: serde_json::json!({
1071 "acceptance": { "implicit_accept_timeout_ms": 0 },
1072 "commitment": { "authority": "initiator_only" }
1073 }),
1074 schema_version: 1,
1075 });
1076 let result = mode
1077 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1078 .unwrap();
1079 apply(&mut session, result);
1080 let result = mode
1081 .on_message(
1082 &session,
1083 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1084 )
1085 .unwrap();
1086 apply(&mut session, result);
1087 let result = mode
1088 .on_message(
1089 &session,
1090 &env("target", "HandoffAccept", make_accept("h1", "target")),
1091 )
1092 .unwrap();
1093 apply(&mut session, result);
1094 let result = mode
1096 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1097 .unwrap();
1098 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1099 }
1100
1101 #[test]
1104 fn second_offer_to_different_target_while_first_pending_rejected() {
1105 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1106 let mut session = base_session();
1107 session.participants = vec!["owner".into(), "targetA".into(), "targetB".into()];
1108 let result = mode
1109 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1110 .unwrap();
1111 apply(&mut session, result);
1112 let result = mode
1114 .on_message(
1115 &session,
1116 &env("owner", "HandoffOffer", make_offer("h1", "targetA")),
1117 )
1118 .unwrap();
1119 apply(&mut session, result);
1120 let err = mode
1122 .on_message(
1123 &session,
1124 &env("owner", "HandoffOffer", make_offer("h2", "targetB")),
1125 )
1126 .unwrap_err();
1127 assert_eq!(err.to_string(), "InvalidPayload");
1128 }
1129
1130 #[test]
1133 fn offer_after_accept_blocked_per_rfc() {
1134 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1137 let mut session = base_session();
1138 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1139 let result = mode
1140 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1141 .unwrap();
1142 apply(&mut session, result);
1143 let result = mode
1144 .on_message(
1145 &session,
1146 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1147 )
1148 .unwrap();
1149 apply(&mut session, result);
1150 let result = mode
1151 .on_message(
1152 &session,
1153 &env("target", "HandoffAccept", make_accept("h1", "target")),
1154 )
1155 .unwrap();
1156 apply(&mut session, result);
1157 let err = mode
1159 .on_message(
1160 &session,
1161 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1162 )
1163 .unwrap_err();
1164 assert_eq!(err.to_string(), "InvalidPayload");
1165 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1166 assert_eq!(state.offers.len(), 1);
1167 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1168 }
1169
1170 #[test]
1171 fn offered_at_ms_is_populated() {
1172 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1173 let mut session = base_session();
1174 session.participants = vec!["owner".into(), "target".into()];
1175 let result = mode
1176 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1177 .unwrap();
1178 apply(&mut session, result);
1179 let result = mode
1180 .on_message(
1181 &session,
1182 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1183 )
1184 .unwrap();
1185 apply(&mut session, result);
1186 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1187 assert!(
1188 state.offers["h1"].offered_at_ms > 0,
1189 "offered_at_ms should be set"
1190 );
1191 }
1192
1193 #[test]
1194 fn implicit_accept_timeout_fires() {
1195 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1198 let mut session = base_session();
1199 session.participants = vec!["owner".into(), "target".into()];
1200 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1201 policy_id: "auto-accept".into(),
1202 mode: "macp.mode.handoff.v1".into(),
1203 description: "short timeout".into(),
1204 rules: serde_json::json!({
1205 "acceptance": { "implicit_accept_timeout_ms": 100 },
1206 "commitment": { "authority": "initiator_only" }
1207 }),
1208 schema_version: 1,
1209 });
1210 let result = mode
1211 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1212 .unwrap();
1213 apply(&mut session, result);
1214 let offer_time = 1000i64;
1216 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1217 offer_env.timestamp_unix_ms = offer_time;
1218 let result = mode.on_message(&session, &offer_env).unwrap();
1219 apply(&mut session, result);
1220 let mut commit_env = env("owner", "Commitment", commitment_payload());
1222 commit_env.timestamp_unix_ms = offer_time + 200; let commit = mode.on_message(&session, &commit_env).unwrap();
1224 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1225 }
1226
1227 fn auto_accept_policy() -> macp_core::policy::PolicyDefinition {
1228 macp_core::policy::PolicyDefinition {
1229 policy_id: "auto-accept".into(),
1230 mode: "macp.mode.handoff.v1".into(),
1231 description: "short timeout".into(),
1232 rules: serde_json::json!({
1233 "acceptance": { "implicit_accept_timeout_ms": 100 },
1234 "commitment": { "authority": "initiator_only" }
1235 }),
1236 schema_version: 1,
1237 }
1238 }
1239
1240 #[test]
1244 fn implicit_accept_ignores_forged_envelope_timestamp_on_rev1() {
1245 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1246 let mut session = base_session();
1247 assert!(session.semantics_rev >= 1, "builder default is current rev");
1248 session.participants = vec!["owner".into(), "target".into()];
1249 session.policy_definition = Some(auto_accept_policy());
1250 let result = mode
1251 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1252 .unwrap();
1253 apply(&mut session, result);
1254
1255 let offer_time = 1000i64;
1256 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1257 offer_env.timestamp_unix_ms = offer_time;
1258 let result = mode.on_message(&session, &offer_env).unwrap();
1259 apply(&mut session, result);
1260
1261 let mut commit_env = env("owner", "Commitment", commitment_payload());
1265 commit_env.timestamp_unix_ms = offer_time + 1_000_000;
1266 let ctx = macp_core::mode::MessageContext::new(offer_time + 50);
1267 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1268 assert_eq!(err.to_string(), "InvalidPayload");
1269
1270 let ctx = macp_core::mode::MessageContext::new(offer_time + 200);
1272 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1273 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1274 }
1275
1276 #[test]
1280 fn implicit_accept_legacy_rev0_keeps_envelope_clock() {
1281 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1282 let mut session = base_session();
1283 session.semantics_rev = 0;
1284 session.participants = vec!["owner".into(), "target".into()];
1285 session.policy_definition = Some(auto_accept_policy());
1286 let result = mode
1287 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1288 .unwrap();
1289 apply(&mut session, result);
1290
1291 let offer_time = 1000i64;
1292 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1293 offer_env.timestamp_unix_ms = offer_time;
1294 let result = mode.on_message(&session, &offer_env).unwrap();
1295 apply(&mut session, result);
1296
1297 let mut commit_env = env("owner", "Commitment", commitment_payload());
1300 commit_env.timestamp_unix_ms = offer_time + 200;
1301 let ctx = macp_core::mode::MessageContext::new(offer_time + 10);
1302 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1303 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1304 }
1305
1306 #[test]
1311 fn implicit_accept_ignores_backdated_offer_timestamp_on_rev1() {
1312 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1313 let mut session = base_session();
1314 assert!(session.semantics_rev >= 1);
1315 session.participants = vec!["owner".into(), "target".into()];
1316 session.policy_definition = Some(auto_accept_policy());
1317 let result = mode
1318 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1319 .unwrap();
1320 apply(&mut session, result);
1321
1322 let now = 1_000_000i64;
1324 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1325 offer_env.timestamp_unix_ms = now - 1_000_000; let ctx = macp_core::mode::MessageContext::new(now);
1327 let result = mode.on_message_at(&session, &offer_env, &ctx).unwrap();
1328 apply(&mut session, result);
1329
1330 let mut commit_env = env("owner", "Commitment", commitment_payload());
1333 commit_env.timestamp_unix_ms = now + 50;
1334 let ctx = macp_core::mode::MessageContext::new(now + 50);
1335 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1336 assert_eq!(err.to_string(), "InvalidPayload");
1337
1338 let ctx = macp_core::mode::MessageContext::new(now + 200);
1340 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1341 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1342 }
1343}