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 let offer = state
237 .offers
238 .get_mut(&payload.handoff_id)
239 .ok_or(MacpError::InvalidPayload)?;
240 if offer.target_participant != env.sender {
241 return Err(MacpError::Forbidden);
242 }
243 if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender {
244 return Err(MacpError::InvalidPayload);
245 }
246 if offer.disposition != HandoffDisposition::Offered {
247 return Err(MacpError::InvalidPayload);
248 }
249 offer.disposition = HandoffDisposition::Accepted;
250 offer.accepted_by = Some(env.sender.clone());
251 offer.outcome_reason = Some(payload.reason);
252 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
253 }
254 "HandoffDecline" => {
255 let payload = HandoffDeclinePayload::decode(&*env.payload)
256 .map_err(|_| MacpError::InvalidPayload)?;
257 let offer = state
258 .offers
259 .get_mut(&payload.handoff_id)
260 .ok_or(MacpError::InvalidPayload)?;
261 if offer.target_participant != env.sender {
262 return Err(MacpError::Forbidden);
263 }
264 if !payload.declined_by.is_empty() && payload.declined_by != env.sender {
265 return Err(MacpError::InvalidPayload);
266 }
267 if offer.disposition != HandoffDisposition::Offered {
268 return Err(MacpError::InvalidPayload);
269 }
270 offer.disposition = HandoffDisposition::Declined;
271 offer.declined_by = Some(env.sender.clone());
272 offer.outcome_reason = Some(payload.reason);
273 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
274 }
275 "Commitment" => {
276 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
277 if let Some(ref policy) = session.policy_definition {
279 let rules: macp_core::policy::rules::HandoffPolicyRules =
280 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
281 if rules.acceptance.implicit_accept_timeout_ms > 0 {
282 let now_ms = clock_ms;
287 let timeout = rules.acceptance.implicit_accept_timeout_ms as i64;
288 for offer in state.offers.values_mut() {
289 if offer.disposition == HandoffDisposition::Offered
290 && offer.offered_at_ms > 0
291 && (now_ms - offer.offered_at_ms) >= timeout
292 {
293 offer.disposition = HandoffDisposition::Accepted;
294 offer.accepted_by = Some(offer.target_participant.clone());
295 offer.outcome_reason = Some("implicit accept (timeout)".into());
296 }
297 }
298 }
299 }
300 if !Self::commitment_ready(&state) {
301 return Err(MacpError::InvalidPayload);
302 }
303 enforce_commitment_policy(
306 session,
307 macp_core::policy::CommitmentMode::Handoff,
308 commitment.outcome_positive,
309 &*self.evaluator,
310 )?;
311 Ok(ModeResponse::PersistAndResolve {
312 state: Self::encode_state(&state),
313 resolution: env.payload.clone(),
314 })
315 }
316 _ => Err(MacpError::InvalidPayload),
317 }
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use macp_core::session::Session;
325 use macp_pb::pb::CommitmentPayload;
326
327 fn base_session() -> Session {
328 Session::builder("s1", "macp.mode.handoff.v1", "owner")
329 .ttl_ms(60_000)
330 .participants(vec!["owner".into(), "target".into()])
331 .mode_version("1.0.0")
332 .configuration_version("config")
333 .policy_version("policy")
334 .build()
335 }
336
337 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
338 Envelope {
339 macp_version: "1.0".into(),
340 mode: "macp.mode.handoff.v1".into(),
341 message_type: message_type.into(),
342 message_id: format!("{}-{}", sender, message_type),
343 session_id: "s1".into(),
344 sender: sender.into(),
345 timestamp_unix_ms: chrono::Utc::now().timestamp_millis(),
346 payload,
347 }
348 }
349
350 fn commitment_payload() -> Vec<u8> {
351 CommitmentPayload {
352 commitment_id: "c1".into(),
353 action: "handoff.accepted".into(),
354 authority_scope: "support".into(),
355 reason: "accepted".into(),
356 mode_version: "1.0.0".into(),
357 policy_version: "policy".into(),
358 configuration_version: "config".into(),
359 outcome_positive: true,
360 supersedes: None,
361 }
362 .encode_to_vec()
363 }
364
365 fn apply(session: &mut Session, result: ModeResponse) {
366 match result {
367 ModeResponse::PersistState(data) => session.mode_state = data,
368 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
369 _ => {}
370 }
371 }
372
373 fn make_offer(handoff_id: &str, target: &str) -> Vec<u8> {
374 HandoffOfferPayload {
375 handoff_id: handoff_id.into(),
376 target_participant: target.into(),
377 scope: "support".into(),
378 reason: "escalate".into(),
379 }
380 .encode_to_vec()
381 }
382
383 fn make_context(handoff_id: &str) -> Vec<u8> {
384 HandoffContextPayload {
385 handoff_id: handoff_id.into(),
386 content_type: "text/plain".into(),
387 context: b"background info".to_vec(),
388 }
389 .encode_to_vec()
390 }
391
392 fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
393 HandoffAcceptPayload {
394 handoff_id: handoff_id.into(),
395 accepted_by: accepted_by.into(),
396 reason: "ready".into(),
397 }
398 .encode_to_vec()
399 }
400
401 fn make_decline(handoff_id: &str, declined_by: &str) -> Vec<u8> {
402 HandoffDeclinePayload {
403 handoff_id: handoff_id.into(),
404 declined_by: declined_by.into(),
405 reason: "busy".into(),
406 }
407 .encode_to_vec()
408 }
409
410 #[test]
413 fn session_start_initializes_state() {
414 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
415 let session = base_session();
416 let result = mode
417 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
418 .unwrap();
419 match result {
420 ModeResponse::PersistState(data) => {
421 let state: HandoffState = serde_json::from_slice(&data).unwrap();
422 assert!(state.offers.is_empty());
423 }
424 _ => panic!("Expected PersistState"),
425 }
426 }
427
428 #[test]
429 fn session_start_requires_two_participants() {
430 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
431 let mut session = base_session();
432 session.participants = vec!["owner".into()]; let err = mode
434 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
435 .unwrap_err();
436 assert_eq!(err.to_string(), "InvalidPayload");
437 }
438
439 #[test]
440 fn session_start_rejects_when_initiator_not_participant() {
441 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
442 let mut session = base_session();
443 session.participants = vec!["target".into(), "other".into()]; let err = mode
445 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
446 .unwrap_err();
447 assert_eq!(err.to_string(), "InvalidPayload");
448 }
449
450 #[test]
453 fn offer_creates_entry() {
454 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
455 let mut session = base_session();
456 let result = mode
457 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
458 .unwrap();
459 apply(&mut session, result);
460 let result = mode
461 .on_message(
462 &session,
463 &env("owner", "HandoffOffer", make_offer("h1", "target")),
464 )
465 .unwrap();
466 match result {
467 ModeResponse::PersistState(data) => {
468 let state: HandoffState = serde_json::from_slice(&data).unwrap();
469 assert!(state.offers.contains_key("h1"));
470 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered);
471 }
472 _ => panic!("Expected PersistState"),
473 }
474 }
475
476 #[test]
477 fn duplicate_offer_id_rejected() {
478 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
479 let mut session = base_session();
480 let result = mode
481 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
482 .unwrap();
483 apply(&mut session, result);
484 let result = mode
485 .on_message(
486 &session,
487 &env("owner", "HandoffOffer", make_offer("h1", "target")),
488 )
489 .unwrap();
490 apply(&mut session, result);
491 let err = mode
492 .on_message(
493 &session,
494 &env("owner", "HandoffOffer", make_offer("h1", "target")),
495 )
496 .unwrap_err();
497 assert_eq!(err.to_string(), "InvalidPayload");
498 }
499
500 #[test]
501 fn offer_to_self_rejected() {
502 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
503 let mut session = base_session();
504 let result = mode
505 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
506 .unwrap();
507 apply(&mut session, result);
508 let err = mode
509 .on_message(
510 &session,
511 &env("owner", "HandoffOffer", make_offer("h1", "owner")),
512 )
513 .unwrap_err();
514 assert_eq!(err.to_string(), "InvalidPayload");
515 }
516
517 #[test]
518 fn offer_to_non_participant_rejected() {
519 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
520 let mut session = base_session();
521 let result = mode
522 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
523 .unwrap();
524 apply(&mut session, result);
525 let err = mode
526 .on_message(
527 &session,
528 &env("owner", "HandoffOffer", make_offer("h1", "outsider")),
529 )
530 .unwrap_err();
531 assert_eq!(err.to_string(), "InvalidPayload");
532 }
533
534 #[test]
537 fn context_for_existing_offer() {
538 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
539 let mut session = base_session();
540 let result = mode
541 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
542 .unwrap();
543 apply(&mut session, result);
544 let result = mode
545 .on_message(
546 &session,
547 &env("owner", "HandoffOffer", make_offer("h1", "target")),
548 )
549 .unwrap();
550 apply(&mut session, result);
551 let result = mode
552 .on_message(
553 &session,
554 &env("owner", "HandoffContext", make_context("h1")),
555 )
556 .unwrap();
557 match result {
558 ModeResponse::PersistState(data) => {
559 let state: HandoffState = serde_json::from_slice(&data).unwrap();
560 assert_eq!(state.contexts["h1"].len(), 1);
561 assert_eq!(state.contexts["h1"][0].content_type, "text/plain");
562 assert_eq!(state.contexts["h1"][0].sender, "owner");
563 }
564 _ => panic!("Expected PersistState"),
565 }
566 }
567
568 #[test]
569 fn context_from_non_offerer_rejected() {
570 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
571 let mut session = base_session();
572 let result = mode
573 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
574 .unwrap();
575 apply(&mut session, result);
576 let result = mode
577 .on_message(
578 &session,
579 &env("owner", "HandoffOffer", make_offer("h1", "target")),
580 )
581 .unwrap();
582 apply(&mut session, result);
583 let err = mode
584 .on_message(
585 &session,
586 &env("target", "HandoffContext", make_context("h1")),
587 )
588 .unwrap_err();
589 assert_eq!(err.to_string(), "Forbidden");
590 }
591
592 #[test]
595 fn target_can_accept() {
596 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
597 let mut session = base_session();
598 let result = mode
599 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
600 .unwrap();
601 apply(&mut session, result);
602 let result = mode
603 .on_message(
604 &session,
605 &env("owner", "HandoffOffer", make_offer("h1", "target")),
606 )
607 .unwrap();
608 apply(&mut session, result);
609 let result = mode
610 .on_message(
611 &session,
612 &env("target", "HandoffAccept", make_accept("h1", "target")),
613 )
614 .unwrap();
615 match result {
616 ModeResponse::PersistState(data) => {
617 let state: HandoffState = serde_json::from_slice(&data).unwrap();
618 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
619 }
620 _ => panic!("Expected PersistState"),
621 }
622 }
623
624 #[test]
625 fn wrong_target_cannot_accept() {
626 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
627 let mut session = base_session();
628 let result = mode
629 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
630 .unwrap();
631 apply(&mut session, result);
632 let result = mode
633 .on_message(
634 &session,
635 &env("owner", "HandoffOffer", make_offer("h1", "target")),
636 )
637 .unwrap();
638 apply(&mut session, result);
639 let err = mode
640 .on_message(
641 &session,
642 &env("owner", "HandoffAccept", make_accept("h1", "owner")),
643 )
644 .unwrap_err();
645 assert_eq!(err.to_string(), "Forbidden");
646 }
647
648 #[test]
649 fn target_can_decline() {
650 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
651 let mut session = base_session();
652 let result = mode
653 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
654 .unwrap();
655 apply(&mut session, result);
656 let result = mode
657 .on_message(
658 &session,
659 &env("owner", "HandoffOffer", make_offer("h1", "target")),
660 )
661 .unwrap();
662 apply(&mut session, result);
663 let result = mode
664 .on_message(
665 &session,
666 &env("target", "HandoffDecline", make_decline("h1", "target")),
667 )
668 .unwrap();
669 match result {
670 ModeResponse::PersistState(data) => {
671 let state: HandoffState = serde_json::from_slice(&data).unwrap();
672 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined);
673 }
674 _ => panic!("Expected PersistState"),
675 }
676 }
677
678 #[test]
679 fn cannot_accept_already_accepted() {
680 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
681 let mut session = base_session();
682 let result = mode
683 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
684 .unwrap();
685 apply(&mut session, result);
686 let result = mode
687 .on_message(
688 &session,
689 &env("owner", "HandoffOffer", make_offer("h1", "target")),
690 )
691 .unwrap();
692 apply(&mut session, result);
693 let result = mode
694 .on_message(
695 &session,
696 &env("target", "HandoffAccept", make_accept("h1", "target")),
697 )
698 .unwrap();
699 apply(&mut session, result);
700 let err = mode
701 .on_message(
702 &session,
703 &env("target", "HandoffAccept", make_accept("h1", "target")),
704 )
705 .unwrap_err();
706 assert_eq!(err.to_string(), "InvalidPayload");
707 }
708
709 #[test]
712 fn commitment_after_accept() {
713 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
714 let mut session = base_session();
715 let result = mode
716 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
717 .unwrap();
718 apply(&mut session, result);
719 let result = mode
720 .on_message(
721 &session,
722 &env("owner", "HandoffOffer", make_offer("h1", "target")),
723 )
724 .unwrap();
725 apply(&mut session, result);
726 let result = mode
727 .on_message(
728 &session,
729 &env("target", "HandoffAccept", make_accept("h1", "target")),
730 )
731 .unwrap();
732 apply(&mut session, result);
733 let result = mode
734 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
735 .unwrap();
736 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
737 }
738
739 #[test]
740 fn commitment_after_decline() {
741 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
742 let mut session = base_session();
743 let result = mode
744 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
745 .unwrap();
746 apply(&mut session, result);
747 let result = mode
748 .on_message(
749 &session,
750 &env("owner", "HandoffOffer", make_offer("h1", "target")),
751 )
752 .unwrap();
753 apply(&mut session, result);
754 let result = mode
755 .on_message(
756 &session,
757 &env("target", "HandoffDecline", make_decline("h1", "target")),
758 )
759 .unwrap();
760 apply(&mut session, result);
761 let result = mode
762 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
763 .unwrap();
764 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
765 }
766
767 #[test]
768 fn commitment_without_response_rejected() {
769 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
770 let mut session = base_session();
771 let result = mode
772 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
773 .unwrap();
774 apply(&mut session, result);
775 let result = mode
776 .on_message(
777 &session,
778 &env("owner", "HandoffOffer", make_offer("h1", "target")),
779 )
780 .unwrap();
781 apply(&mut session, result);
782 let err = mode
783 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
784 .unwrap_err();
785 assert_eq!(err.to_string(), "InvalidPayload");
786 }
787
788 #[test]
789 fn commitment_with_no_offers_rejected() {
790 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
791 let mut session = base_session();
792 let result = mode
793 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
794 .unwrap();
795 apply(&mut session, result);
796 let err = mode
797 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
798 .unwrap_err();
799 assert_eq!(err.to_string(), "InvalidPayload");
800 }
801
802 #[test]
805 fn full_handoff_lifecycle() {
806 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
807 let mut session = base_session();
808 let result = mode
809 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
810 .unwrap();
811 apply(&mut session, result);
812 let result = mode
813 .on_message(
814 &session,
815 &env("owner", "HandoffOffer", make_offer("h1", "target")),
816 )
817 .unwrap();
818 apply(&mut session, result);
819 let result = mode
820 .on_message(
821 &session,
822 &env("owner", "HandoffContext", make_context("h1")),
823 )
824 .unwrap();
825 apply(&mut session, result);
826 let result = mode
827 .on_message(
828 &session,
829 &env("target", "HandoffAccept", make_accept("h1", "target")),
830 )
831 .unwrap();
832 apply(&mut session, result);
833 let result = mode
834 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
835 .unwrap();
836 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
837 }
838
839 #[test]
842 fn second_offer_while_first_pending_rejected() {
843 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
844 let mut session = base_session();
845 session.participants = vec!["owner".into(), "target".into(), "other".into()];
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 err = mode
858 .on_message(
859 &session,
860 &env("owner", "HandoffOffer", make_offer("h2", "other")),
861 )
862 .unwrap_err();
863 assert_eq!(err.to_string(), "InvalidPayload");
864 }
865
866 #[test]
867 fn second_offer_after_first_accepted_is_rejected() {
868 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
870 let mut session = base_session();
871 session.participants = vec!["owner".into(), "target".into(), "other".into()];
872 let result = mode
873 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
874 .unwrap();
875 apply(&mut session, result);
876 let result = mode
877 .on_message(
878 &session,
879 &env("owner", "HandoffOffer", make_offer("h1", "target")),
880 )
881 .unwrap();
882 apply(&mut session, result);
883 let result = mode
884 .on_message(
885 &session,
886 &env("target", "HandoffAccept", make_accept("h1", "target")),
887 )
888 .unwrap();
889 apply(&mut session, result);
890 let err = mode
891 .on_message(
892 &session,
893 &env("owner", "HandoffOffer", make_offer("h2", "other")),
894 )
895 .unwrap_err();
896 assert_eq!(err.to_string(), "InvalidPayload");
897 }
898
899 #[test]
900 fn second_offer_after_first_declined_succeeds() {
901 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
902 let mut session = base_session();
903 session.participants = vec!["owner".into(), "target".into(), "other".into()];
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", "HandoffDecline", make_decline("h1", "target")),
919 )
920 .unwrap();
921 apply(&mut session, result);
922 mode.on_message(
923 &session,
924 &env("owner", "HandoffOffer", make_offer("h2", "other")),
925 )
926 .unwrap();
927 }
928
929 #[test]
932 fn commitment_version_mismatch_rejected() {
933 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
934 let mut session = base_session();
935 let result = mode
936 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
937 .unwrap();
938 apply(&mut session, result);
939 let result = mode
940 .on_message(
941 &session,
942 &env("owner", "HandoffOffer", make_offer("h1", "target")),
943 )
944 .unwrap();
945 apply(&mut session, result);
946 let result = mode
947 .on_message(
948 &session,
949 &env("target", "HandoffAccept", make_accept("h1", "target")),
950 )
951 .unwrap();
952 apply(&mut session, result);
953 let bad_commitment = CommitmentPayload {
954 commitment_id: "c1".into(),
955 action: "handoff.accepted".into(),
956 authority_scope: "support".into(),
957 reason: "accepted".into(),
958 mode_version: "wrong".into(),
959 policy_version: "policy".into(),
960 configuration_version: "config".into(),
961 outcome_positive: true,
962 supersedes: None,
963 }
964 .encode_to_vec();
965 let err = mode
966 .on_message(&session, &env("owner", "Commitment", bad_commitment))
967 .unwrap_err();
968 assert_eq!(err.to_string(), "InvalidPayload");
969 }
970
971 #[test]
974 fn unknown_message_type_rejected() {
975 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
976 let mut session = base_session();
977 let result = mode
978 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
979 .unwrap();
980 apply(&mut session, result);
981 let err = mode
982 .on_message(&session, &env("owner", "CustomType", vec![]))
983 .unwrap_err();
984 assert_eq!(err.to_string(), "InvalidPayload");
985 }
986
987 #[test]
988 fn context_after_accept_is_permitted() {
989 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
992 let mut session = base_session();
993 let resp = mode
994 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
995 .unwrap();
996 apply(&mut session, resp);
997 let resp = mode
998 .on_message(
999 &session,
1000 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1001 )
1002 .unwrap();
1003 apply(&mut session, resp);
1004 let resp = mode
1005 .on_message(
1006 &session,
1007 &env("target", "HandoffAccept", make_accept("h1", "target")),
1008 )
1009 .unwrap();
1010 apply(&mut session, resp);
1011 let result = mode.on_message(
1013 &session,
1014 &env("owner", "HandoffContext", make_context("h1")),
1015 );
1016 assert!(
1017 result.is_ok(),
1018 "late HandoffContext should be permitted per RFC"
1019 );
1020 }
1021
1022 #[test]
1025 fn handoff_policy_evaluator_always_allows() {
1026 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1027 let mut session = base_session();
1028 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1029 policy_id: "test-handoff".into(),
1030 mode: "macp.mode.handoff.v1".into(),
1031 description: "handoff policy".into(),
1032 rules: serde_json::json!({
1033 "acceptance": { "implicit_accept_timeout_ms": 0 },
1034 "commitment": { "authority": "initiator_only" }
1035 }),
1036 schema_version: 1,
1037 });
1038 let result = mode
1039 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1040 .unwrap();
1041 apply(&mut session, result);
1042 let result = mode
1043 .on_message(
1044 &session,
1045 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1046 )
1047 .unwrap();
1048 apply(&mut session, result);
1049 let result = mode
1050 .on_message(
1051 &session,
1052 &env("target", "HandoffAccept", make_accept("h1", "target")),
1053 )
1054 .unwrap();
1055 apply(&mut session, result);
1056 let result = mode
1058 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1059 .unwrap();
1060 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1061 }
1062
1063 #[test]
1066 fn second_offer_to_different_target_while_first_pending_rejected() {
1067 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1068 let mut session = base_session();
1069 session.participants = vec!["owner".into(), "targetA".into(), "targetB".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
1076 .on_message(
1077 &session,
1078 &env("owner", "HandoffOffer", make_offer("h1", "targetA")),
1079 )
1080 .unwrap();
1081 apply(&mut session, result);
1082 let err = mode
1084 .on_message(
1085 &session,
1086 &env("owner", "HandoffOffer", make_offer("h2", "targetB")),
1087 )
1088 .unwrap_err();
1089 assert_eq!(err.to_string(), "InvalidPayload");
1090 }
1091
1092 #[test]
1095 fn offer_after_accept_blocked_per_rfc() {
1096 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1099 let mut session = base_session();
1100 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1101 let result = mode
1102 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1103 .unwrap();
1104 apply(&mut session, result);
1105 let result = mode
1106 .on_message(
1107 &session,
1108 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1109 )
1110 .unwrap();
1111 apply(&mut session, result);
1112 let result = mode
1113 .on_message(
1114 &session,
1115 &env("target", "HandoffAccept", make_accept("h1", "target")),
1116 )
1117 .unwrap();
1118 apply(&mut session, result);
1119 let err = mode
1121 .on_message(
1122 &session,
1123 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1124 )
1125 .unwrap_err();
1126 assert_eq!(err.to_string(), "InvalidPayload");
1127 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1128 assert_eq!(state.offers.len(), 1);
1129 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1130 }
1131
1132 #[test]
1133 fn offered_at_ms_is_populated() {
1134 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1135 let mut session = base_session();
1136 session.participants = vec!["owner".into(), "target".into()];
1137 let result = mode
1138 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1139 .unwrap();
1140 apply(&mut session, result);
1141 let result = mode
1142 .on_message(
1143 &session,
1144 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1145 )
1146 .unwrap();
1147 apply(&mut session, result);
1148 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1149 assert!(
1150 state.offers["h1"].offered_at_ms > 0,
1151 "offered_at_ms should be set"
1152 );
1153 }
1154
1155 #[test]
1156 fn implicit_accept_timeout_fires() {
1157 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1160 let mut session = base_session();
1161 session.participants = vec!["owner".into(), "target".into()];
1162 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1163 policy_id: "auto-accept".into(),
1164 mode: "macp.mode.handoff.v1".into(),
1165 description: "short timeout".into(),
1166 rules: serde_json::json!({
1167 "acceptance": { "implicit_accept_timeout_ms": 100 },
1168 "commitment": { "authority": "initiator_only" }
1169 }),
1170 schema_version: 1,
1171 });
1172 let result = mode
1173 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1174 .unwrap();
1175 apply(&mut session, result);
1176 let offer_time = 1000i64;
1178 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1179 offer_env.timestamp_unix_ms = offer_time;
1180 let result = mode.on_message(&session, &offer_env).unwrap();
1181 apply(&mut session, result);
1182 let mut commit_env = env("owner", "Commitment", commitment_payload());
1184 commit_env.timestamp_unix_ms = offer_time + 200; let commit = mode.on_message(&session, &commit_env).unwrap();
1186 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1187 }
1188
1189 fn auto_accept_policy() -> macp_core::policy::PolicyDefinition {
1190 macp_core::policy::PolicyDefinition {
1191 policy_id: "auto-accept".into(),
1192 mode: "macp.mode.handoff.v1".into(),
1193 description: "short timeout".into(),
1194 rules: serde_json::json!({
1195 "acceptance": { "implicit_accept_timeout_ms": 100 },
1196 "commitment": { "authority": "initiator_only" }
1197 }),
1198 schema_version: 1,
1199 }
1200 }
1201
1202 #[test]
1206 fn implicit_accept_ignores_forged_envelope_timestamp_on_rev1() {
1207 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1208 let mut session = base_session();
1209 assert!(session.semantics_rev >= 1, "builder default is current rev");
1210 session.participants = vec!["owner".into(), "target".into()];
1211 session.policy_definition = Some(auto_accept_policy());
1212 let result = mode
1213 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1214 .unwrap();
1215 apply(&mut session, result);
1216
1217 let offer_time = 1000i64;
1218 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1219 offer_env.timestamp_unix_ms = offer_time;
1220 let result = mode.on_message(&session, &offer_env).unwrap();
1221 apply(&mut session, result);
1222
1223 let mut commit_env = env("owner", "Commitment", commitment_payload());
1227 commit_env.timestamp_unix_ms = offer_time + 1_000_000;
1228 let ctx = macp_core::mode::MessageContext::new(offer_time + 50);
1229 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1230 assert_eq!(err.to_string(), "InvalidPayload");
1231
1232 let ctx = macp_core::mode::MessageContext::new(offer_time + 200);
1234 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1235 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1236 }
1237
1238 #[test]
1242 fn implicit_accept_legacy_rev0_keeps_envelope_clock() {
1243 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1244 let mut session = base_session();
1245 session.semantics_rev = 0;
1246 session.participants = vec!["owner".into(), "target".into()];
1247 session.policy_definition = Some(auto_accept_policy());
1248 let result = mode
1249 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1250 .unwrap();
1251 apply(&mut session, result);
1252
1253 let offer_time = 1000i64;
1254 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1255 offer_env.timestamp_unix_ms = offer_time;
1256 let result = mode.on_message(&session, &offer_env).unwrap();
1257 apply(&mut session, result);
1258
1259 let mut commit_env = env("owner", "Commitment", commitment_payload());
1262 commit_env.timestamp_unix_ms = offer_time + 200;
1263 let ctx = macp_core::mode::MessageContext::new(offer_time + 10);
1264 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1265 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1266 }
1267
1268 #[test]
1273 fn implicit_accept_ignores_backdated_offer_timestamp_on_rev1() {
1274 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1275 let mut session = base_session();
1276 assert!(session.semantics_rev >= 1);
1277 session.participants = vec!["owner".into(), "target".into()];
1278 session.policy_definition = Some(auto_accept_policy());
1279 let result = mode
1280 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1281 .unwrap();
1282 apply(&mut session, result);
1283
1284 let now = 1_000_000i64;
1286 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1287 offer_env.timestamp_unix_ms = now - 1_000_000; let ctx = macp_core::mode::MessageContext::new(now);
1289 let result = mode.on_message_at(&session, &offer_env, &ctx).unwrap();
1290 apply(&mut session, result);
1291
1292 let mut commit_env = env("owner", "Commitment", commitment_payload());
1295 commit_env.timestamp_unix_ms = now + 50;
1296 let ctx = macp_core::mode::MessageContext::new(now + 50);
1297 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1298 assert_eq!(err.to_string(), "InvalidPayload");
1299
1300 let ctx = macp_core::mode::MessageContext::new(now + 200);
1302 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1303 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1304 }
1305}