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
16pub const IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX: &str = "implicit-accept:";
35
36const IMPLICIT_ACCEPT_REASON: &str = "implicit accept (timeout)";
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub enum HandoffDisposition {
56 Offered,
57 Accepted,
58 Declined,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
82#[non_exhaustive]
83pub struct HandoffOfferRecord {
84 pub handoff_id: String,
85 pub target_participant: String,
86 pub scope: String,
87 pub reason: String,
88 pub offered_by: String,
89 pub disposition: HandoffDisposition,
90 pub accepted_by: Option<String>,
91 pub declined_by: Option<String>,
92 pub outcome_reason: Option<String>,
93 #[serde(default)]
94 pub offered_at_ms: i64,
95 #[serde(default)]
108 pub suspended_ms_at_offer: i64,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
115#[non_exhaustive]
116pub struct HandoffContextRecord {
117 pub content_type: String,
118 pub context: Vec<u8>,
119 pub sender: String,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129#[non_exhaustive]
130pub struct HandoffState {
131 pub offers: BTreeMap<String, HandoffOfferRecord>,
132 pub contexts: BTreeMap<String, Vec<HandoffContextRecord>>,
133}
134
135pub struct HandoffMode {
136 evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
137}
138
139impl HandoffMode {
140 pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
142 Self { evaluator }
143 }
144
145 fn encode_state(state: &HandoffState) -> Vec<u8> {
146 crate::mode::util::encode_mode_state(state)
147 }
148
149 fn decode_state(data: &[u8]) -> Result<HandoffState, MacpError> {
150 crate::mode::util::decode_mode_state(data)
151 }
152
153 fn implicit_accept_elapsed_ms(
165 session: &Session,
166 offer: &HandoffOfferRecord,
167 now_ms: i64,
168 ) -> i64 {
169 if session.semantics_rev >= 2 {
170 Self::rev2_elapsed_ms(session, offer, now_ms)
171 } else {
172 now_ms - offer.offered_at_ms
181 }
182 }
183
184 fn rev2_elapsed_ms(session: &Session, offer: &HandoffOfferRecord, now_ms: i64) -> i64 {
219 let suspended_since_offer = session
220 .accumulated_suspended_ms
221 .saturating_sub(offer.suspended_ms_at_offer)
222 .max(0);
223 now_ms
224 .saturating_sub(offer.offered_at_ms)
225 .saturating_sub(suspended_since_offer)
226 }
227
228 fn implicit_accept_timeout_ms(session: &Session) -> i64 {
237 let Some(policy) = session.policy_definition.as_ref() else {
238 return 0;
239 };
240 let rules: macp_core::policy::rules::HandoffPolicyRules =
241 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
242 rules.acceptance.implicit_accept_timeout_ms as i64
243 }
244
245 fn commitment_ready(state: &HandoffState) -> bool {
246 state.offers.values().any(|offer| {
247 offer.disposition == HandoffDisposition::Accepted
248 || offer.disposition == HandoffDisposition::Declined
249 })
250 }
251}
252
253impl Mode for HandoffMode {
254 fn validate_client_envelope(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
277 if session.semantics_rev < 2 {
278 return Ok(());
279 }
280 if env
281 .message_id
282 .starts_with(IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX)
283 {
284 return Err(MacpError::InvalidEnvelope);
285 }
286 if env.message_type == "HandoffAccept" {
287 if let Ok(payload) = HandoffAcceptPayload::decode(&*env.payload) {
291 if payload.implicit {
292 return Err(MacpError::InvalidPayload);
293 }
294 }
295 }
296 Ok(())
297 }
298
299 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
300 match env.message_type.as_str() {
301 "Commitment" => check_commitment_authority(session, &env.sender),
302 "HandoffOffer" if env.sender == session.initiator_sender => Ok(()),
304 "HandoffOffer" => Err(MacpError::Forbidden),
305 _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
307 _ => Err(MacpError::Forbidden),
308 }
309 }
310
311 fn on_session_start(
312 &self,
313 session: &Session,
314 _env: &Envelope,
315 ) -> Result<ModeResponse, MacpError> {
316 if session.participants.len() < 2 {
324 return Err(MacpError::InvalidPayload);
325 }
326 if !session
327 .participants
328 .iter()
329 .any(|p| p == &session.initiator_sender)
330 {
331 return Err(MacpError::InvalidPayload);
332 }
333 Ok(ModeResponse::PersistState(Self::encode_state(
334 &HandoffState::default(),
335 )))
336 }
337
338 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
339 self.handle_message(session, env, env.timestamp_unix_ms)
344 }
345
346 fn on_message_at(
347 &self,
348 session: &Session,
349 env: &Envelope,
350 ctx: &macp_core::mode::MessageContext,
351 ) -> Result<ModeResponse, MacpError> {
352 let clock_ms = if session.semantics_rev >= 1 {
359 ctx.accepted_at_ms
360 } else {
361 env.timestamp_unix_ms
362 };
363 self.handle_message(session, env, clock_ms)
364 }
365
366 fn due_synthetic_envelope(&self, session: &Session, now_ms: i64) -> Option<Envelope> {
404 if session.semantics_rev < 2 {
405 return None;
406 }
407 let timeout = Self::implicit_accept_timeout_ms(session);
408 if timeout <= 0 {
409 return None;
410 }
411 if session.mode_state.is_empty() {
412 return None;
413 }
414 let state = Self::decode_state(&session.mode_state).ok()?;
415 let offer = state
420 .offers
421 .values()
422 .find(|offer| offer.disposition == HandoffDisposition::Offered)?;
423 if offer.offered_at_ms <= 0 {
428 return None;
429 }
430 if Self::implicit_accept_elapsed_ms(session, offer, now_ms) < timeout {
431 return None;
432 }
433 debug_assert!(
437 session.suspended_at_ms.is_none(),
438 "due_synthetic_envelope must not be asked about a suspended session"
439 );
440 let deadline = session.unsuspended_deadline(offer.offered_at_ms, timeout);
441 debug_assert!(
442 deadline <= now_ms,
443 "deadline {deadline} is in the future of the observation {now_ms}"
444 );
445 Some(Envelope {
446 macp_version: macp_core::MACP_VERSION.into(),
447 mode: session.mode.clone(),
448 message_type: "HandoffAccept".into(),
449 message_id: format!(
450 "{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{}",
451 offer.handoff_id.as_str()
452 ),
453 session_id: session.session_id.clone(),
454 sender: offer.target_participant.clone(),
455 timestamp_unix_ms: deadline,
458 payload: HandoffAcceptPayload {
459 handoff_id: offer.handoff_id.clone(),
460 accepted_by: offer.target_participant.clone(),
461 reason: IMPLICIT_ACCEPT_REASON.into(),
462 implicit: true,
463 }
464 .encode_to_vec(),
465 })
466 }
467}
468
469impl HandoffMode {
470 fn handle_message(
471 &self,
472 session: &Session,
473 env: &Envelope,
474 clock_ms: i64,
475 ) -> Result<ModeResponse, MacpError> {
476 let mut state = if session.mode_state.is_empty() {
477 HandoffState::default()
478 } else {
479 Self::decode_state(&session.mode_state)?
480 };
481
482 match env.message_type.as_str() {
483 "HandoffOffer" => {
484 let payload = HandoffOfferPayload::decode(&*env.payload)
485 .map_err(|_| MacpError::InvalidPayload)?;
486 if payload.handoff_id.is_empty()
489 || payload.target_participant.is_empty()
490 || state.offers.contains_key(&payload.handoff_id)
491 || !is_declared_participant(&session.participants, &payload.target_participant)
492 || payload.target_participant == env.sender
493 || state
494 .offers
495 .values()
496 .any(|o| o.disposition == HandoffDisposition::Offered)
497 || state
498 .offers
499 .values()
500 .any(|o| o.disposition == HandoffDisposition::Accepted)
501 {
502 return Err(MacpError::InvalidPayload);
503 }
504 state.offers.insert(
505 payload.handoff_id.clone(),
506 HandoffOfferRecord {
507 handoff_id: payload.handoff_id,
508 target_participant: payload.target_participant,
509 scope: payload.scope,
510 reason: payload.reason,
511 offered_by: env.sender.clone(),
512 disposition: HandoffDisposition::Offered,
513 accepted_by: None,
514 declined_by: None,
515 outcome_reason: None,
516 offered_at_ms: if session.semantics_rev >= 1 {
525 clock_ms
526 } else {
527 env.timestamp_unix_ms
528 },
529 suspended_ms_at_offer: session.accumulated_suspended_ms,
535 },
536 );
537 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
538 }
539 "HandoffContext" => {
540 let payload = HandoffContextPayload::decode(&*env.payload)
541 .map_err(|_| MacpError::InvalidPayload)?;
542 let offer = state
543 .offers
544 .get(&payload.handoff_id)
545 .ok_or(MacpError::InvalidPayload)?;
546 if offer.offered_by != env.sender {
547 return Err(MacpError::Forbidden);
548 }
549 state
552 .contexts
553 .entry(payload.handoff_id)
554 .or_default()
555 .push(HandoffContextRecord {
556 content_type: payload.content_type,
557 context: payload.context,
558 sender: env.sender.clone(),
559 });
560 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
561 }
562 "HandoffAccept" => {
563 let payload = HandoffAcceptPayload::decode(&*env.payload)
564 .map_err(|_| MacpError::InvalidPayload)?;
565 if payload.implicit {
566 return self.dispatch_implicit_accept(session, env, payload, state);
567 }
568 let offer = state
569 .offers
570 .get_mut(&payload.handoff_id)
571 .ok_or(MacpError::InvalidPayload)?;
572 if offer.target_participant != env.sender {
573 return Err(MacpError::Forbidden);
574 }
575 if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender {
576 return Err(MacpError::InvalidPayload);
577 }
578 if offer.disposition != HandoffDisposition::Offered {
579 return Err(MacpError::InvalidPayload);
580 }
581 offer.disposition = HandoffDisposition::Accepted;
582 offer.accepted_by = Some(env.sender.clone());
583 offer.outcome_reason = Some(payload.reason);
584 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
585 }
586 "HandoffDecline" => {
587 let payload = HandoffDeclinePayload::decode(&*env.payload)
588 .map_err(|_| MacpError::InvalidPayload)?;
589 let offer = state
590 .offers
591 .get_mut(&payload.handoff_id)
592 .ok_or(MacpError::InvalidPayload)?;
593 if offer.target_participant != env.sender {
594 return Err(MacpError::Forbidden);
595 }
596 if !payload.declined_by.is_empty() && payload.declined_by != env.sender {
597 return Err(MacpError::InvalidPayload);
598 }
599 if offer.disposition != HandoffDisposition::Offered {
600 return Err(MacpError::InvalidPayload);
601 }
602 offer.disposition = HandoffDisposition::Declined;
603 offer.declined_by = Some(env.sender.clone());
604 offer.outcome_reason = Some(payload.reason);
605 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
606 }
607 "Commitment" => {
608 let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
609 if let Some(ref policy) = session.policy_definition {
611 let rules: macp_core::policy::rules::HandoffPolicyRules =
612 serde_json::from_value(policy.rules.clone()).unwrap_or_default();
613 if rules.acceptance.implicit_accept_timeout_ms > 0 {
614 let now_ms = clock_ms;
619 let timeout = rules.acceptance.implicit_accept_timeout_ms as i64;
620 if session.semantics_rev < 2 {
638 for offer in state.offers.values_mut() {
639 if offer.disposition == HandoffDisposition::Offered
640 && offer.offered_at_ms > 0
641 && Self::implicit_accept_elapsed_ms(session, offer, now_ms)
642 >= timeout
643 {
644 offer.disposition = HandoffDisposition::Accepted;
645 offer.accepted_by = Some(offer.target_participant.clone());
646 offer.outcome_reason = Some(IMPLICIT_ACCEPT_REASON.into());
647 }
648 }
649 }
650 }
651 }
652 if !Self::commitment_ready(&state) {
653 return Err(MacpError::InvalidPayload);
654 }
655 enforce_commitment_policy(
658 session,
659 macp_core::policy::CommitmentMode::Handoff,
660 commitment.outcome_positive,
661 &*self.evaluator,
662 )?;
663 Ok(ModeResponse::PersistAndResolve {
664 state: Self::encode_state(&state),
665 resolution: env.payload.clone(),
666 })
667 }
668 _ => Err(MacpError::InvalidPayload),
669 }
670 }
671
672 fn dispatch_implicit_accept(
706 &self,
707 session: &Session,
708 env: &Envelope,
709 payload: HandoffAcceptPayload,
710 mut state: HandoffState,
711 ) -> Result<ModeResponse, MacpError> {
712 if session.semantics_rev < 2 {
713 return Err(MacpError::InvalidPayload);
714 }
715 let offer = state
716 .offers
717 .get_mut(&payload.handoff_id)
718 .ok_or(MacpError::InvalidPayload)?;
719 if offer.target_participant != env.sender {
722 return Err(MacpError::Forbidden);
723 }
724 if payload.accepted_by != offer.target_participant {
728 return Err(MacpError::InvalidPayload);
729 }
730 if env.message_id
734 != format!(
735 "{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{}",
736 offer.handoff_id.as_str()
737 )
738 {
739 return Err(MacpError::InvalidPayload);
740 }
741 if offer.disposition != HandoffDisposition::Offered {
742 return Err(MacpError::InvalidPayload);
743 }
744 offer.disposition = HandoffDisposition::Accepted;
757 offer.accepted_by = Some(offer.target_participant.clone());
758 offer.outcome_reason = Some(payload.reason);
759 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use macp_core::session::Session;
767 use macp_pb::pb::CommitmentPayload;
768
769 fn base_session() -> Session {
770 Session::builder("s1", "macp.mode.handoff.v1", "owner")
771 .ttl_ms(60_000)
772 .participants(vec!["owner".into(), "target".into()])
773 .mode_version("1.0.0")
774 .configuration_version("config")
775 .policy_version("policy")
776 .build()
777 }
778
779 fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
780 Envelope {
781 macp_version: "1.0".into(),
782 mode: "macp.mode.handoff.v1".into(),
783 message_type: message_type.into(),
784 message_id: format!("{}-{}", sender, message_type),
785 session_id: "s1".into(),
786 sender: sender.into(),
787 timestamp_unix_ms: chrono::Utc::now().timestamp_millis(),
788 payload,
789 }
790 }
791
792 fn commitment_payload() -> Vec<u8> {
793 CommitmentPayload {
794 commitment_id: "c1".into(),
795 action: "handoff.accepted".into(),
796 authority_scope: "support".into(),
797 reason: "accepted".into(),
798 mode_version: "1.0.0".into(),
799 policy_version: "policy".into(),
800 configuration_version: "config".into(),
801 outcome_positive: true,
802 supersedes: None,
803 }
804 .encode_to_vec()
805 }
806
807 fn apply(session: &mut Session, result: ModeResponse) {
808 match result {
809 ModeResponse::PersistState(data) => session.mode_state = data,
810 ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
811 _ => {}
812 }
813 }
814
815 fn make_offer(handoff_id: &str, target: &str) -> Vec<u8> {
816 HandoffOfferPayload {
817 handoff_id: handoff_id.into(),
818 target_participant: target.into(),
819 scope: "support".into(),
820 reason: "escalate".into(),
821 }
822 .encode_to_vec()
823 }
824
825 fn make_context(handoff_id: &str) -> Vec<u8> {
826 HandoffContextPayload {
827 handoff_id: handoff_id.into(),
828 content_type: "text/plain".into(),
829 context: b"background info".to_vec(),
830 }
831 .encode_to_vec()
832 }
833
834 fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
835 HandoffAcceptPayload {
836 handoff_id: handoff_id.into(),
837 accepted_by: accepted_by.into(),
838 reason: "ready".into(),
839 implicit: false,
840 }
841 .encode_to_vec()
842 }
843
844 fn make_decline(handoff_id: &str, declined_by: &str) -> Vec<u8> {
845 HandoffDeclinePayload {
846 handoff_id: handoff_id.into(),
847 declined_by: declined_by.into(),
848 reason: "busy".into(),
849 }
850 .encode_to_vec()
851 }
852
853 #[test]
856 fn session_start_initializes_state() {
857 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
858 let session = base_session();
859 let result = mode
860 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
861 .unwrap();
862 match result {
863 ModeResponse::PersistState(data) => {
864 let state: HandoffState = serde_json::from_slice(&data).unwrap();
865 assert!(state.offers.is_empty());
866 }
867 _ => panic!("Expected PersistState"),
868 }
869 }
870
871 #[test]
872 fn session_start_requires_two_participants() {
873 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
874 let mut session = base_session();
875 session.participants = vec!["owner".into()]; let err = mode
877 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
878 .unwrap_err();
879 assert_eq!(err.to_string(), "InvalidPayload");
880 }
881
882 #[test]
883 fn session_start_rejects_when_initiator_not_participant() {
884 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
885 let mut session = base_session();
886 session.participants = vec!["target".into(), "other".into()]; let err = mode
888 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
889 .unwrap_err();
890 assert_eq!(err.to_string(), "InvalidPayload");
891 }
892
893 #[test]
896 fn offer_creates_entry() {
897 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
898 let mut session = base_session();
899 let result = mode
900 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
901 .unwrap();
902 apply(&mut session, result);
903 let result = mode
904 .on_message(
905 &session,
906 &env("owner", "HandoffOffer", make_offer("h1", "target")),
907 )
908 .unwrap();
909 match result {
910 ModeResponse::PersistState(data) => {
911 let state: HandoffState = serde_json::from_slice(&data).unwrap();
912 assert!(state.offers.contains_key("h1"));
913 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered);
914 }
915 _ => panic!("Expected PersistState"),
916 }
917 }
918
919 #[test]
920 fn duplicate_offer_id_rejected() {
921 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
922 let mut session = base_session();
923 let result = mode
924 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
925 .unwrap();
926 apply(&mut session, result);
927 let result = mode
928 .on_message(
929 &session,
930 &env("owner", "HandoffOffer", make_offer("h1", "target")),
931 )
932 .unwrap();
933 apply(&mut session, result);
934 let err = mode
935 .on_message(
936 &session,
937 &env("owner", "HandoffOffer", make_offer("h1", "target")),
938 )
939 .unwrap_err();
940 assert_eq!(err.to_string(), "InvalidPayload");
941 }
942
943 #[test]
944 fn offer_to_self_rejected() {
945 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
946 let mut session = base_session();
947 let result = mode
948 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
949 .unwrap();
950 apply(&mut session, result);
951 let err = mode
952 .on_message(
953 &session,
954 &env("owner", "HandoffOffer", make_offer("h1", "owner")),
955 )
956 .unwrap_err();
957 assert_eq!(err.to_string(), "InvalidPayload");
958 }
959
960 #[test]
961 fn offer_to_non_participant_rejected() {
962 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
963 let mut session = base_session();
964 let result = mode
965 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
966 .unwrap();
967 apply(&mut session, result);
968 let err = mode
969 .on_message(
970 &session,
971 &env("owner", "HandoffOffer", make_offer("h1", "outsider")),
972 )
973 .unwrap_err();
974 assert_eq!(err.to_string(), "InvalidPayload");
975 }
976
977 #[test]
980 fn context_for_existing_offer() {
981 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
982 let mut session = base_session();
983 let result = mode
984 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
985 .unwrap();
986 apply(&mut session, result);
987 let result = mode
988 .on_message(
989 &session,
990 &env("owner", "HandoffOffer", make_offer("h1", "target")),
991 )
992 .unwrap();
993 apply(&mut session, result);
994 let result = mode
995 .on_message(
996 &session,
997 &env("owner", "HandoffContext", make_context("h1")),
998 )
999 .unwrap();
1000 match result {
1001 ModeResponse::PersistState(data) => {
1002 let state: HandoffState = serde_json::from_slice(&data).unwrap();
1003 assert_eq!(state.contexts["h1"].len(), 1);
1004 assert_eq!(state.contexts["h1"][0].content_type, "text/plain");
1005 assert_eq!(state.contexts["h1"][0].sender, "owner");
1006 }
1007 _ => panic!("Expected PersistState"),
1008 }
1009 }
1010
1011 #[test]
1012 fn context_from_non_offerer_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 result = mode
1020 .on_message(
1021 &session,
1022 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1023 )
1024 .unwrap();
1025 apply(&mut session, result);
1026 let err = mode
1027 .on_message(
1028 &session,
1029 &env("target", "HandoffContext", make_context("h1")),
1030 )
1031 .unwrap_err();
1032 assert_eq!(err.to_string(), "Forbidden");
1033 }
1034
1035 #[test]
1038 fn target_can_accept() {
1039 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1040 let mut session = base_session();
1041 let result = mode
1042 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1043 .unwrap();
1044 apply(&mut session, result);
1045 let result = mode
1046 .on_message(
1047 &session,
1048 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1049 )
1050 .unwrap();
1051 apply(&mut session, result);
1052 let result = mode
1053 .on_message(
1054 &session,
1055 &env("target", "HandoffAccept", make_accept("h1", "target")),
1056 )
1057 .unwrap();
1058 match result {
1059 ModeResponse::PersistState(data) => {
1060 let state: HandoffState = serde_json::from_slice(&data).unwrap();
1061 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1062 }
1063 _ => panic!("Expected PersistState"),
1064 }
1065 }
1066
1067 #[test]
1087 fn implicit_accept_with_a_non_reserved_message_id_is_rejected() {
1088 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1089 let mut session = base_session();
1090 let result = mode
1091 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1092 .unwrap();
1093 apply(&mut session, result);
1094 let result = mode
1095 .on_message(
1096 &session,
1097 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1098 )
1099 .unwrap();
1100 apply(&mut session, result);
1101 let forged_accept = HandoffAcceptPayload {
1102 handoff_id: "h1".into(),
1103 accepted_by: "target".into(),
1104 reason: "ready".into(),
1105 implicit: true,
1106 }
1107 .encode_to_vec();
1108 let err = mode
1109 .on_message(&session, &env("target", "HandoffAccept", forged_accept))
1110 .unwrap_err();
1111 assert_eq!(err.to_string(), "InvalidPayload");
1112
1113 let mut legacy = session.clone();
1119 legacy.semantics_rev = 1;
1120 let mut perfect = env(
1121 "target",
1122 "HandoffAccept",
1123 make_implicit_accept("h1", "target"),
1124 );
1125 perfect.message_id = reserved_id("h1");
1126 assert_eq!(
1127 mode.on_message(&legacy, &perfect).unwrap_err().to_string(),
1128 "InvalidPayload"
1129 );
1130 assert!(mode.on_message(&session, &perfect).is_ok());
1135 }
1136
1137 #[test]
1138 fn wrong_target_cannot_accept() {
1139 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1140 let mut session = base_session();
1141 let result = mode
1142 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1143 .unwrap();
1144 apply(&mut session, result);
1145 let result = mode
1146 .on_message(
1147 &session,
1148 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1149 )
1150 .unwrap();
1151 apply(&mut session, result);
1152 let err = mode
1153 .on_message(
1154 &session,
1155 &env("owner", "HandoffAccept", make_accept("h1", "owner")),
1156 )
1157 .unwrap_err();
1158 assert_eq!(err.to_string(), "Forbidden");
1159 }
1160
1161 #[test]
1162 fn target_can_decline() {
1163 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1164 let mut session = base_session();
1165 let result = mode
1166 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1167 .unwrap();
1168 apply(&mut session, result);
1169 let result = mode
1170 .on_message(
1171 &session,
1172 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1173 )
1174 .unwrap();
1175 apply(&mut session, result);
1176 let result = mode
1177 .on_message(
1178 &session,
1179 &env("target", "HandoffDecline", make_decline("h1", "target")),
1180 )
1181 .unwrap();
1182 match result {
1183 ModeResponse::PersistState(data) => {
1184 let state: HandoffState = serde_json::from_slice(&data).unwrap();
1185 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined);
1186 }
1187 _ => panic!("Expected PersistState"),
1188 }
1189 }
1190
1191 #[test]
1192 fn cannot_accept_already_accepted() {
1193 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1194 let mut session = base_session();
1195 let result = mode
1196 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1197 .unwrap();
1198 apply(&mut session, result);
1199 let result = mode
1200 .on_message(
1201 &session,
1202 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1203 )
1204 .unwrap();
1205 apply(&mut session, result);
1206 let result = mode
1207 .on_message(
1208 &session,
1209 &env("target", "HandoffAccept", make_accept("h1", "target")),
1210 )
1211 .unwrap();
1212 apply(&mut session, result);
1213 let err = mode
1214 .on_message(
1215 &session,
1216 &env("target", "HandoffAccept", make_accept("h1", "target")),
1217 )
1218 .unwrap_err();
1219 assert_eq!(err.to_string(), "InvalidPayload");
1220 }
1221
1222 #[test]
1225 fn commitment_after_accept() {
1226 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1227 let mut session = base_session();
1228 let result = mode
1229 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1230 .unwrap();
1231 apply(&mut session, result);
1232 let result = mode
1233 .on_message(
1234 &session,
1235 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1236 )
1237 .unwrap();
1238 apply(&mut session, result);
1239 let result = mode
1240 .on_message(
1241 &session,
1242 &env("target", "HandoffAccept", make_accept("h1", "target")),
1243 )
1244 .unwrap();
1245 apply(&mut session, result);
1246 let result = mode
1247 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1248 .unwrap();
1249 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1250 }
1251
1252 #[test]
1253 fn commitment_after_decline() {
1254 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1255 let mut session = base_session();
1256 let result = mode
1257 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1258 .unwrap();
1259 apply(&mut session, result);
1260 let result = mode
1261 .on_message(
1262 &session,
1263 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1264 )
1265 .unwrap();
1266 apply(&mut session, result);
1267 let result = mode
1268 .on_message(
1269 &session,
1270 &env("target", "HandoffDecline", make_decline("h1", "target")),
1271 )
1272 .unwrap();
1273 apply(&mut session, result);
1274 let result = mode
1275 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1276 .unwrap();
1277 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1278 }
1279
1280 #[test]
1281 fn commitment_without_response_rejected() {
1282 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1283 let mut session = base_session();
1284 let result = mode
1285 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1286 .unwrap();
1287 apply(&mut session, result);
1288 let result = mode
1289 .on_message(
1290 &session,
1291 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1292 )
1293 .unwrap();
1294 apply(&mut session, result);
1295 let err = mode
1296 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1297 .unwrap_err();
1298 assert_eq!(err.to_string(), "InvalidPayload");
1299 }
1300
1301 #[test]
1302 fn commitment_with_no_offers_rejected() {
1303 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1304 let mut session = base_session();
1305 let result = mode
1306 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1307 .unwrap();
1308 apply(&mut session, result);
1309 let err = mode
1310 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1311 .unwrap_err();
1312 assert_eq!(err.to_string(), "InvalidPayload");
1313 }
1314
1315 #[test]
1318 fn full_handoff_lifecycle() {
1319 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1320 let mut session = base_session();
1321 let result = mode
1322 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1323 .unwrap();
1324 apply(&mut session, result);
1325 let result = mode
1326 .on_message(
1327 &session,
1328 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1329 )
1330 .unwrap();
1331 apply(&mut session, result);
1332 let result = mode
1333 .on_message(
1334 &session,
1335 &env("owner", "HandoffContext", make_context("h1")),
1336 )
1337 .unwrap();
1338 apply(&mut session, result);
1339 let result = mode
1340 .on_message(
1341 &session,
1342 &env("target", "HandoffAccept", make_accept("h1", "target")),
1343 )
1344 .unwrap();
1345 apply(&mut session, result);
1346 let result = mode
1347 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1348 .unwrap();
1349 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1350 }
1351
1352 #[test]
1355 fn second_offer_while_first_pending_rejected() {
1356 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1357 let mut session = base_session();
1358 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1359 let result = mode
1360 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1361 .unwrap();
1362 apply(&mut session, result);
1363 let result = mode
1364 .on_message(
1365 &session,
1366 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1367 )
1368 .unwrap();
1369 apply(&mut session, result);
1370 let err = mode
1371 .on_message(
1372 &session,
1373 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1374 )
1375 .unwrap_err();
1376 assert_eq!(err.to_string(), "InvalidPayload");
1377 }
1378
1379 #[test]
1380 fn second_offer_after_first_accepted_is_rejected() {
1381 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1383 let mut session = base_session();
1384 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1385 let result = mode
1386 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1387 .unwrap();
1388 apply(&mut session, result);
1389 let result = mode
1390 .on_message(
1391 &session,
1392 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1393 )
1394 .unwrap();
1395 apply(&mut session, result);
1396 let result = mode
1397 .on_message(
1398 &session,
1399 &env("target", "HandoffAccept", make_accept("h1", "target")),
1400 )
1401 .unwrap();
1402 apply(&mut session, result);
1403 let err = mode
1404 .on_message(
1405 &session,
1406 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1407 )
1408 .unwrap_err();
1409 assert_eq!(err.to_string(), "InvalidPayload");
1410 }
1411
1412 #[test]
1413 fn second_offer_after_first_declined_succeeds() {
1414 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1415 let mut session = base_session();
1416 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1417 let result = mode
1418 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1419 .unwrap();
1420 apply(&mut session, result);
1421 let result = mode
1422 .on_message(
1423 &session,
1424 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1425 )
1426 .unwrap();
1427 apply(&mut session, result);
1428 let result = mode
1429 .on_message(
1430 &session,
1431 &env("target", "HandoffDecline", make_decline("h1", "target")),
1432 )
1433 .unwrap();
1434 apply(&mut session, result);
1435 mode.on_message(
1436 &session,
1437 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1438 )
1439 .unwrap();
1440 }
1441
1442 #[test]
1445 fn commitment_version_mismatch_rejected() {
1446 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1447 let mut session = base_session();
1448 let result = mode
1449 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1450 .unwrap();
1451 apply(&mut session, result);
1452 let result = mode
1453 .on_message(
1454 &session,
1455 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1456 )
1457 .unwrap();
1458 apply(&mut session, result);
1459 let result = mode
1460 .on_message(
1461 &session,
1462 &env("target", "HandoffAccept", make_accept("h1", "target")),
1463 )
1464 .unwrap();
1465 apply(&mut session, result);
1466 let bad_commitment = CommitmentPayload {
1467 commitment_id: "c1".into(),
1468 action: "handoff.accepted".into(),
1469 authority_scope: "support".into(),
1470 reason: "accepted".into(),
1471 mode_version: "wrong".into(),
1472 policy_version: "policy".into(),
1473 configuration_version: "config".into(),
1474 outcome_positive: true,
1475 supersedes: None,
1476 }
1477 .encode_to_vec();
1478 let err = mode
1479 .on_message(&session, &env("owner", "Commitment", bad_commitment))
1480 .unwrap_err();
1481 assert_eq!(err.to_string(), "InvalidPayload");
1482 }
1483
1484 #[test]
1487 fn unknown_message_type_rejected() {
1488 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1489 let mut session = base_session();
1490 let result = mode
1491 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1492 .unwrap();
1493 apply(&mut session, result);
1494 let err = mode
1495 .on_message(&session, &env("owner", "CustomType", vec![]))
1496 .unwrap_err();
1497 assert_eq!(err.to_string(), "InvalidPayload");
1498 }
1499
1500 #[test]
1501 fn context_after_accept_is_permitted() {
1502 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1505 let mut session = base_session();
1506 let resp = mode
1507 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1508 .unwrap();
1509 apply(&mut session, resp);
1510 let resp = mode
1511 .on_message(
1512 &session,
1513 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1514 )
1515 .unwrap();
1516 apply(&mut session, resp);
1517 let resp = mode
1518 .on_message(
1519 &session,
1520 &env("target", "HandoffAccept", make_accept("h1", "target")),
1521 )
1522 .unwrap();
1523 apply(&mut session, resp);
1524 let result = mode.on_message(
1526 &session,
1527 &env("owner", "HandoffContext", make_context("h1")),
1528 );
1529 assert!(
1530 result.is_ok(),
1531 "late HandoffContext should be permitted per RFC"
1532 );
1533 }
1534
1535 #[test]
1538 fn handoff_policy_evaluator_always_allows() {
1539 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1540 let mut session = base_session();
1541 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1542 policy_id: "test-handoff".into(),
1543 mode: "macp.mode.handoff.v1".into(),
1544 description: "handoff policy".into(),
1545 rules: serde_json::json!({
1546 "acceptance": { "implicit_accept_timeout_ms": 0 },
1547 "commitment": { "authority": "initiator_only" }
1548 }),
1549 schema_version: 1,
1550 });
1551 let result = mode
1552 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1553 .unwrap();
1554 apply(&mut session, result);
1555 let result = mode
1556 .on_message(
1557 &session,
1558 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1559 )
1560 .unwrap();
1561 apply(&mut session, result);
1562 let result = mode
1563 .on_message(
1564 &session,
1565 &env("target", "HandoffAccept", make_accept("h1", "target")),
1566 )
1567 .unwrap();
1568 apply(&mut session, result);
1569 let result = mode
1571 .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1572 .unwrap();
1573 assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1574 }
1575
1576 #[test]
1579 fn second_offer_to_different_target_while_first_pending_rejected() {
1580 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1581 let mut session = base_session();
1582 session.participants = vec!["owner".into(), "targetA".into(), "targetB".into()];
1583 let result = mode
1584 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1585 .unwrap();
1586 apply(&mut session, result);
1587 let result = mode
1589 .on_message(
1590 &session,
1591 &env("owner", "HandoffOffer", make_offer("h1", "targetA")),
1592 )
1593 .unwrap();
1594 apply(&mut session, result);
1595 let err = mode
1597 .on_message(
1598 &session,
1599 &env("owner", "HandoffOffer", make_offer("h2", "targetB")),
1600 )
1601 .unwrap_err();
1602 assert_eq!(err.to_string(), "InvalidPayload");
1603 }
1604
1605 #[test]
1608 fn offer_after_accept_blocked_per_rfc() {
1609 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1612 let mut session = base_session();
1613 session.participants = vec!["owner".into(), "target".into(), "other".into()];
1614 let result = mode
1615 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1616 .unwrap();
1617 apply(&mut session, result);
1618 let result = mode
1619 .on_message(
1620 &session,
1621 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1622 )
1623 .unwrap();
1624 apply(&mut session, result);
1625 let result = mode
1626 .on_message(
1627 &session,
1628 &env("target", "HandoffAccept", make_accept("h1", "target")),
1629 )
1630 .unwrap();
1631 apply(&mut session, result);
1632 let err = mode
1634 .on_message(
1635 &session,
1636 &env("owner", "HandoffOffer", make_offer("h2", "other")),
1637 )
1638 .unwrap_err();
1639 assert_eq!(err.to_string(), "InvalidPayload");
1640 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1641 assert_eq!(state.offers.len(), 1);
1642 assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1643 }
1644
1645 #[test]
1646 fn offered_at_ms_is_populated() {
1647 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1648 let mut session = base_session();
1649 session.participants = vec!["owner".into(), "target".into()];
1650 let result = mode
1651 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1652 .unwrap();
1653 apply(&mut session, result);
1654 let result = mode
1655 .on_message(
1656 &session,
1657 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1658 )
1659 .unwrap();
1660 apply(&mut session, result);
1661 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1662 assert!(
1663 state.offers["h1"].offered_at_ms > 0,
1664 "offered_at_ms should be set"
1665 );
1666 }
1667
1668 #[test]
1680 fn implicit_accept_timeout_fires() {
1681 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1684 let mut session = base_session();
1685 session.semantics_rev = 1;
1686 session.participants = vec!["owner".into(), "target".into()];
1687 session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1688 policy_id: "auto-accept".into(),
1689 mode: "macp.mode.handoff.v1".into(),
1690 description: "short timeout".into(),
1691 rules: serde_json::json!({
1692 "acceptance": { "implicit_accept_timeout_ms": 100 },
1693 "commitment": { "authority": "initiator_only" }
1694 }),
1695 schema_version: 1,
1696 });
1697 let result = mode
1698 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1699 .unwrap();
1700 apply(&mut session, result);
1701 let offer_time = 1000i64;
1703 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1704 offer_env.timestamp_unix_ms = offer_time;
1705 let result = mode.on_message(&session, &offer_env).unwrap();
1706 apply(&mut session, result);
1707 let mut commit_env = env("owner", "Commitment", commitment_payload());
1709 commit_env.timestamp_unix_ms = offer_time + 200; let commit = mode.on_message(&session, &commit_env).unwrap();
1711 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1712 }
1713
1714 fn auto_accept_policy() -> macp_core::policy::PolicyDefinition {
1715 macp_core::policy::PolicyDefinition {
1716 policy_id: "auto-accept".into(),
1717 mode: "macp.mode.handoff.v1".into(),
1718 description: "short timeout".into(),
1719 rules: serde_json::json!({
1720 "acceptance": { "implicit_accept_timeout_ms": 100 },
1721 "commitment": { "authority": "initiator_only" }
1722 }),
1723 schema_version: 1,
1724 }
1725 }
1726
1727 #[test]
1737 fn implicit_accept_ignores_forged_envelope_timestamp_on_rev1() {
1738 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1739 let mut session = base_session();
1740 session.semantics_rev = 1;
1741 session.participants = vec!["owner".into(), "target".into()];
1742 session.policy_definition = Some(auto_accept_policy());
1743 let result = mode
1744 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1745 .unwrap();
1746 apply(&mut session, result);
1747
1748 let offer_time = 1000i64;
1749 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1750 offer_env.timestamp_unix_ms = offer_time;
1751 let result = mode.on_message(&session, &offer_env).unwrap();
1752 apply(&mut session, result);
1753
1754 let mut commit_env = env("owner", "Commitment", commitment_payload());
1758 commit_env.timestamp_unix_ms = offer_time + 1_000_000;
1759 let ctx = macp_core::mode::MessageContext::new(offer_time + 50);
1760 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1761 assert_eq!(err.to_string(), "InvalidPayload");
1762
1763 let ctx = macp_core::mode::MessageContext::new(offer_time + 200);
1765 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1766 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1767
1768 let mut rev2 = session.clone();
1773 rev2.semantics_rev = 2;
1774 assert!(
1775 mode.due_synthetic_envelope(&rev2, offer_time + 50)
1776 .is_none(),
1777 "50ms of kernel time must not make the accept due, whatever the envelope claims"
1778 );
1779 assert!(mode
1780 .due_synthetic_envelope(&rev2, offer_time + 200)
1781 .is_some());
1782 }
1783
1784 #[test]
1788 fn implicit_accept_legacy_rev0_keeps_envelope_clock() {
1789 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1790 let mut session = base_session();
1791 session.semantics_rev = 0;
1792 session.participants = vec!["owner".into(), "target".into()];
1793 session.policy_definition = Some(auto_accept_policy());
1794 let result = mode
1795 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1796 .unwrap();
1797 apply(&mut session, result);
1798
1799 let offer_time = 1000i64;
1800 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1801 offer_env.timestamp_unix_ms = offer_time;
1802 let result = mode.on_message(&session, &offer_env).unwrap();
1803 apply(&mut session, result);
1804
1805 let mut commit_env = env("owner", "Commitment", commitment_payload());
1808 commit_env.timestamp_unix_ms = offer_time + 200;
1809 let ctx = macp_core::mode::MessageContext::new(offer_time + 10);
1810 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1811 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1812 }
1813
1814 #[test]
1823 fn implicit_accept_ignores_backdated_offer_timestamp_on_rev1() {
1824 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1825 let mut session = base_session();
1826 session.semantics_rev = 1;
1827 session.participants = vec!["owner".into(), "target".into()];
1828 session.policy_definition = Some(auto_accept_policy());
1829 let result = mode
1830 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1831 .unwrap();
1832 apply(&mut session, result);
1833
1834 let now = 1_000_000i64;
1836 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1837 offer_env.timestamp_unix_ms = now - 1_000_000; let ctx = macp_core::mode::MessageContext::new(now);
1839 let result = mode.on_message_at(&session, &offer_env, &ctx).unwrap();
1840 apply(&mut session, result);
1841
1842 let mut commit_env = env("owner", "Commitment", commitment_payload());
1845 commit_env.timestamp_unix_ms = now + 50;
1846 let ctx = macp_core::mode::MessageContext::new(now + 50);
1847 let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1848 assert_eq!(err.to_string(), "InvalidPayload");
1849
1850 let ctx = macp_core::mode::MessageContext::new(now + 200);
1852 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1853 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1854
1855 let mut rev2 = session.clone();
1859 rev2.semantics_rev = 2;
1860 assert!(mode.due_synthetic_envelope(&rev2, now + 50).is_none());
1861 assert_eq!(
1862 mode.due_synthetic_envelope(&rev2, now + 200)
1863 .expect("due once 100ms of kernel time has elapsed")
1864 .timestamp_unix_ms,
1865 now + 100,
1866 "the deadline is measured from the acceptance clock, not the forged envelope"
1867 );
1868 }
1869
1870 #[test]
1874 fn offer_records_suspension_snapshot() {
1875 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1876 let mut session = base_session();
1877 session.accumulated_suspended_ms = 5_000;
1879 let result = mode
1880 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1881 .unwrap();
1882 apply(&mut session, result);
1883
1884 let ctx = macp_core::mode::MessageContext::new(1_000);
1885 let result = mode
1886 .on_message_at(
1887 &session,
1888 &env("owner", "HandoffOffer", make_offer("h1", "target")),
1889 &ctx,
1890 )
1891 .unwrap();
1892 apply(&mut session, result);
1893
1894 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1895 assert_eq!(state.offers["h1"].suspended_ms_at_offer, 5_000);
1896 }
1897
1898 #[test]
1908 fn legacy_offer_mode_state_without_suspension_snapshot_replays_unchanged() {
1909 let legacy_state = serde_json::json!({
1910 "offers": {
1911 "h1": {
1912 "handoff_id": "h1",
1913 "target_participant": "target",
1914 "scope": "support",
1915 "reason": "escalate",
1916 "offered_by": "owner",
1917 "disposition": "Offered",
1918 "accepted_by": null,
1919 "declined_by": null,
1920 "outcome_reason": null,
1921 "offered_at_ms": 1000
1922 }
1923 },
1924 "contexts": {}
1925 });
1926 let decoded: HandoffState = serde_json::from_value(legacy_state.clone()).unwrap();
1927 assert_eq!(decoded.offers["h1"].suspended_ms_at_offer, 0);
1928 assert_eq!(decoded.offers["h1"].offered_at_ms, 1000);
1929
1930 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1934 let mut session = base_session();
1935 session.semantics_rev = 1;
1936 session.policy_definition = Some(auto_accept_policy());
1937 session.mode_state = serde_json::to_vec(&legacy_state).unwrap();
1938 let commit_env = env("owner", "Commitment", commitment_payload());
1939 let ctx = macp_core::mode::MessageContext::new(1_200);
1940 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1941 apply(&mut session, commit);
1942 let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1943 assert_eq!(
1944 state.offers["h1"].outcome_reason.as_deref(),
1945 Some("implicit accept (timeout)")
1946 );
1947 }
1948
1949 const OFFER_TIME_MS: i64 = 1_000;
1951
1952 fn implicit_accept_outcome(
1968 rev: u32,
1969 suspended_after_offer_ms: i64,
1970 commit_at: i64,
1971 ) -> Result<bool, String> {
1972 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1973 let mut session = base_session();
1974 session.semantics_rev = rev;
1975 session.policy_definition = Some(auto_accept_policy());
1976 let result = mode
1977 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1978 .unwrap();
1979 apply(&mut session, result);
1980
1981 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1982 offer_env.timestamp_unix_ms = OFFER_TIME_MS;
1983 let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
1984 let result = mode
1985 .on_message_at(&session, &offer_env, &offer_ctx)
1986 .unwrap();
1987 apply(&mut session, result);
1988
1989 if suspended_after_offer_ms > 0 {
1994 session.suspend(OFFER_TIME_MS).unwrap();
1995 session
1996 .resume(OFFER_TIME_MS + suspended_after_offer_ms)
1997 .unwrap();
1998 assert_eq!(session.accumulated_suspended_ms, suspended_after_offer_ms);
1999 assert_eq!(session.suspended_at_ms, None);
2000 }
2001
2002 let mut commit_env = env("owner", "Commitment", commitment_payload());
2003 commit_env.timestamp_unix_ms = commit_at;
2004 let ctx = macp_core::mode::MessageContext::new(commit_at);
2005 mode.on_message_at(&session, &commit_env, &ctx)
2006 .map(|r| matches!(r, ModeResponse::PersistAndResolve { .. }))
2007 .map_err(|e| e.to_string())
2008 }
2009
2010 fn implicit_accept_outcome_via_hook(
2025 suspended_after_offer_ms: i64,
2026 commit_at: i64,
2027 ) -> Result<bool, String> {
2028 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2029 let mut session = base_session();
2030 session.semantics_rev = 2;
2031 session.policy_definition = Some(auto_accept_policy());
2032 let result = mode
2033 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2034 .unwrap();
2035 apply(&mut session, result);
2036
2037 let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
2038 offer_env.timestamp_unix_ms = OFFER_TIME_MS;
2039 let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2040 let result = mode
2041 .on_message_at(&session, &offer_env, &offer_ctx)
2042 .unwrap();
2043 apply(&mut session, result);
2044
2045 if suspended_after_offer_ms > 0 {
2046 session.suspend(OFFER_TIME_MS).unwrap();
2047 session
2048 .resume(OFFER_TIME_MS + suspended_after_offer_ms)
2049 .unwrap();
2050 }
2051
2052 if let Some(syn) = mode.due_synthetic_envelope(&session, commit_at) {
2054 mode.authorize_sender(&session, &syn)
2055 .map_err(|e| e.to_string())?;
2056 let syn_ctx = macp_core::mode::MessageContext::new(syn.timestamp_unix_ms);
2057 let response = mode
2058 .on_message_at(&session, &syn, &syn_ctx)
2059 .map_err(|e| e.to_string())?;
2060 apply(&mut session, response);
2061 }
2062
2063 let mut commit_env = env("owner", "Commitment", commitment_payload());
2064 commit_env.timestamp_unix_ms = commit_at;
2065 let ctx = macp_core::mode::MessageContext::new(commit_at);
2066 mode.on_message_at(&session, &commit_env, &ctx)
2067 .map(|r| matches!(r, ModeResponse::PersistAndResolve { .. }))
2068 .map_err(|e| e.to_string())
2069 }
2070
2071 #[test]
2079 fn rev2_stops_counting_suspended_time_toward_implicit_accept() {
2080 let commit_at = OFFER_TIME_MS + 300;
2081 assert_eq!(
2082 implicit_accept_outcome(2, 250, commit_at),
2083 Err("InvalidPayload".into()),
2084 "rev 2: 50ms of unsuspended time must not implicitly accept"
2085 );
2086 assert_eq!(
2087 implicit_accept_outcome(1, 250, commit_at),
2088 Ok(true),
2089 "rev 1 must keep the legacy arithmetic exactly (suspended time counts)"
2090 );
2091 assert_eq!(
2092 implicit_accept_outcome(0, 250, commit_at),
2093 Ok(true),
2094 "rev 0 must keep the legacy arithmetic exactly (suspended time counts)"
2095 );
2096 }
2097
2098 #[test]
2110 fn rev2_matches_legacy_arithmetic_when_nothing_was_suspended() {
2111 for (suspended, commit_at, expected) in [
2112 (0i64, OFFER_TIME_MS + 200, Ok(true)),
2114 (0, OFFER_TIME_MS + 50, Err("InvalidPayload".to_string())),
2115 (200, OFFER_TIME_MS + 300, Ok(true)),
2117 (201, OFFER_TIME_MS + 300, Err("InvalidPayload".to_string())),
2119 ] {
2120 assert_eq!(
2121 implicit_accept_outcome_via_hook(suspended, commit_at),
2122 expected,
2123 "rev 2 via the synthesis hook (suspended={suspended}, commit_at={commit_at})"
2124 );
2125 assert_eq!(
2129 implicit_accept_outcome(2, suspended, commit_at),
2130 Err("InvalidPayload".to_string()),
2131 "rev 2 must not implicitly accept without the synthetic entry \
2132 (suspended={suspended}, commit_at={commit_at})"
2133 );
2134 if suspended == 0 {
2135 assert_eq!(
2136 implicit_accept_outcome(1, suspended, commit_at),
2137 expected,
2138 "rev 1 must agree when nothing was suspended"
2139 );
2140 }
2141 }
2142 }
2143
2144 #[test]
2150 fn rev2_subtracts_only_suspension_accrued_after_the_offer() {
2151 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2152 let mut session = base_session();
2153 session.policy_definition = Some(auto_accept_policy());
2154 session.accumulated_suspended_ms = 5_000;
2156 let result = mode
2157 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2158 .unwrap();
2159 apply(&mut session, result);
2160
2161 let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2162 let result = mode
2163 .on_message_at(
2164 &session,
2165 &env("owner", "HandoffOffer", make_offer("h1", "target")),
2166 &offer_ctx,
2167 )
2168 .unwrap();
2169 apply(&mut session, result);
2170
2171 let syn = mode
2178 .due_synthetic_envelope(&session, OFFER_TIME_MS + 200)
2179 .expect("the 5s pause ended before the offer and must not push the deadline out");
2180 assert_eq!(
2181 syn.timestamp_unix_ms,
2182 OFFER_TIME_MS + 100,
2183 "the pre-offer pause must not move the deadline"
2184 );
2185 let syn_ctx = macp_core::mode::MessageContext::new(syn.timestamp_unix_ms);
2186 let response = mode.on_message_at(&session, &syn, &syn_ctx).unwrap();
2187 apply(&mut session, response);
2188
2189 let commit_env = env("owner", "Commitment", commitment_payload());
2191 let ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS + 200);
2192 let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
2193 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2194 }
2195
2196 #[test]
2202 fn rev2_elapsed_ms_is_saturating_and_floors_the_suspension_term() {
2203 let mut session = base_session();
2204 let mut offer = HandoffOfferRecord {
2205 handoff_id: "h1".into(),
2206 target_participant: "target".into(),
2207 scope: "support".into(),
2208 reason: "escalate".into(),
2209 offered_by: "owner".into(),
2210 disposition: HandoffDisposition::Offered,
2211 accepted_by: None,
2212 declined_by: None,
2213 outcome_reason: None,
2214 offered_at_ms: 1_000,
2215 suspended_ms_at_offer: 0,
2216 };
2217
2218 assert_eq!(HandoffMode::rev2_elapsed_ms(&session, &offer, 1_300), 300);
2220
2221 offer.suspended_ms_at_offer = 5_000;
2225 session.accumulated_suspended_ms = 1_000;
2226 assert_eq!(HandoffMode::rev2_elapsed_ms(&session, &offer, 1_300), 300);
2227
2228 offer.suspended_ms_at_offer = 0;
2230 session.accumulated_suspended_ms = 0;
2231 offer.offered_at_ms = i64::MIN;
2232 assert_eq!(
2233 HandoffMode::rev2_elapsed_ms(&session, &offer, i64::MAX),
2234 i64::MAX
2235 );
2236 offer.offered_at_ms = 0;
2237 session.accumulated_suspended_ms = i64::MAX;
2238 assert_eq!(
2239 HandoffMode::rev2_elapsed_ms(&session, &offer, i64::MIN),
2240 i64::MIN
2241 );
2242 }
2243
2244 #[test]
2248 fn builder_default_session_is_on_current_semantics_rev() {
2249 assert_eq!(
2250 base_session().semantics_rev,
2251 macp_core::session::CURRENT_SEMANTICS_REV
2252 );
2253 const _: () = assert!(macp_core::session::CURRENT_SEMANTICS_REV >= 2);
2256 }
2257
2258 fn make_implicit_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
2269 HandoffAcceptPayload {
2270 handoff_id: handoff_id.into(),
2271 accepted_by: accepted_by.into(),
2272 reason: "implicit accept (timeout)".into(),
2273 implicit: true,
2274 }
2275 .encode_to_vec()
2276 }
2277
2278 fn reserved_id(handoff_id: &str) -> String {
2279 format!("{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{handoff_id}")
2280 }
2281
2282 #[test]
2300 fn client_implicit_accept_rejected_at_the_boundary() {
2301 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2302 let session = base_session();
2303
2304 let mut a = env(
2306 "target",
2307 "HandoffAccept",
2308 make_implicit_accept("h1", "target"),
2309 );
2310 assert!(
2311 !a.message_id.starts_with(IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX),
2312 "case (a) must not also trip the reserved-namespace rule"
2313 );
2314 assert!(matches!(
2315 mode.validate_client_envelope(&session, &a).unwrap_err(),
2316 MacpError::InvalidPayload
2317 ));
2318
2319 a.payload = make_accept("h1", "target");
2322 assert!(mode.validate_client_envelope(&session, &a).is_ok());
2323
2324 let mut b = env(
2326 "target",
2327 "HandoffAccept",
2328 make_implicit_accept("h1", "target"),
2329 );
2330 b.message_id = reserved_id("h1");
2331 assert!(matches!(
2332 mode.validate_client_envelope(&session, &b).unwrap_err(),
2333 MacpError::InvalidEnvelope
2334 ));
2335 }
2336
2337 #[test]
2347 fn reserved_prefix_is_rejected_for_every_message_type() {
2348 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2349 let session = base_session();
2350
2351 for message_type in [
2352 "SessionStart",
2353 "Commitment",
2354 "HandoffOffer",
2355 "HandoffContext",
2356 "HandoffAccept",
2357 "HandoffDecline",
2358 "SomeUnknownType",
2359 ] {
2360 for suffix in ["h1", "", "a-handoff-id-that-does-not-exist-yet"] {
2361 let mut e = env("owner", message_type, vec![]);
2362 e.message_id = format!("{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{suffix}");
2363 assert!(
2364 matches!(
2365 mode.validate_client_envelope(&session, &e).unwrap_err(),
2366 MacpError::InvalidEnvelope
2367 ),
2368 "{message_type} with id {} must be rejected",
2369 e.message_id
2370 );
2371 }
2372
2373 let mut ok = env("owner", message_type, vec![]);
2376 ok.message_id = "implicit-accept".into(); assert!(
2378 mode.validate_client_envelope(&session, &ok).is_ok(),
2379 "{message_type} with a near-miss id must pass"
2380 );
2381 }
2382 }
2383
2384 #[test]
2391 fn reserved_namespace_is_rev_gated() {
2392 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2393
2394 let mut reserved = env("owner", "HandoffContext", make_context("h1"));
2395 reserved.message_id = reserved_id("h1");
2396 let implicit = env(
2397 "target",
2398 "HandoffAccept",
2399 make_implicit_accept("h1", "target"),
2400 );
2401
2402 for rev in [0, 1] {
2403 let mut legacy = base_session();
2404 legacy.semantics_rev = rev;
2405 assert!(
2406 mode.validate_client_envelope(&legacy, &reserved).is_ok(),
2407 "rev {rev} must accept a reserved-prefix id"
2408 );
2409 assert!(
2410 mode.validate_client_envelope(&legacy, &implicit).is_ok(),
2411 "rev {rev} must leave the implicit flag to dispatch"
2412 );
2413 }
2414
2415 let current = base_session();
2416 assert_eq!(
2417 current.semantics_rev,
2418 macp_core::session::CURRENT_SEMANTICS_REV
2419 );
2420 assert!(mode.validate_client_envelope(¤t, &reserved).is_err());
2421 assert!(mode.validate_client_envelope(¤t, &implicit).is_err());
2422 }
2423
2424 #[test]
2429 fn undecodable_handoff_accept_is_left_to_dispatch() {
2430 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2431 let mut session = base_session();
2432
2433 let garbage = vec![0xffu8, 0xff, 0xff, 0xff];
2436 assert!(HandoffAcceptPayload::decode(&*garbage).is_err());
2437
2438 let e = env("target", "HandoffAccept", garbage);
2439 assert!(
2440 mode.validate_client_envelope(&session, &e).is_ok(),
2441 "the boundary must not decide an undecodable payload"
2442 );
2443
2444 let result = mode
2446 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2447 .unwrap();
2448 apply(&mut session, result);
2449 assert!(matches!(
2450 mode.on_message(&session, &e).unwrap_err(),
2451 MacpError::InvalidPayload
2452 ));
2453 }
2454
2455 #[test]
2460 fn client_boundary_runs_after_sender_authorization() {
2461 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2462 let mut session = base_session();
2463 let result = mode
2464 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2465 .unwrap();
2466 apply(&mut session, result);
2467
2468 let mut e = env(
2472 "stranger",
2473 "HandoffAccept",
2474 make_implicit_accept("h1", "stranger"),
2475 );
2476 e.message_id = reserved_id("h1");
2477 assert!(matches!(
2478 crate::step::validate_message(&session, &e, &mode).unwrap_err(),
2479 MacpError::Forbidden
2480 ));
2481
2482 let mut authorized = e.clone();
2484 authorized.sender = "target".into();
2485 assert!(matches!(
2486 crate::step::validate_message(&session, &authorized, &mode).unwrap_err(),
2487 MacpError::InvalidEnvelope
2488 ));
2489 }
2490
2491 fn offered_session() -> (HandoffMode, Session) {
2499 let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2500 let mut session = base_session();
2501 session.policy_definition = Some(auto_accept_policy());
2502 let result = mode
2503 .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2504 .unwrap();
2505 apply(&mut session, result);
2506 let ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2507 let result = mode
2508 .on_message_at(
2509 &session,
2510 &env("owner", "HandoffOffer", make_offer("h1", "target")),
2511 &ctx,
2512 )
2513 .unwrap();
2514 apply(&mut session, result);
2515 (mode, session)
2516 }
2517
2518 fn bound_timeout_ms(session: &Session) -> i64 {
2521 let timeout = HandoffMode::implicit_accept_timeout_ms(session);
2522 assert_eq!(timeout, 100, "auto_accept_policy binds a 100ms timeout");
2523 timeout
2524 }
2525
2526 #[test]
2536 fn due_synthetic_envelope_emits_at_the_deadline() {
2537 let (mode, session) = offered_session();
2538 let timeout = bound_timeout_ms(&session);
2539 let deadline = OFFER_TIME_MS + timeout;
2540
2541 assert!(mode
2543 .due_synthetic_envelope(&session, deadline - 1)
2544 .is_none());
2545
2546 let at_deadline = mode
2549 .due_synthetic_envelope(&session, deadline)
2550 .expect("due at the deadline");
2551 let much_later = mode
2552 .due_synthetic_envelope(&session, deadline + 10_000)
2553 .expect("still due long after the deadline");
2554 assert_eq!(at_deadline, much_later);
2555
2556 assert_eq!(at_deadline.macp_version, "1.0");
2558 assert_eq!(at_deadline.mode, session.mode);
2559 assert_eq!(at_deadline.session_id, session.session_id);
2560 assert_eq!(at_deadline.message_type, "HandoffAccept");
2561 assert_eq!(at_deadline.message_id, "implicit-accept:h1");
2562 assert_eq!(at_deadline.sender, "target");
2563 assert_eq!(at_deadline.timestamp_unix_ms, deadline);
2564 let payload = HandoffAcceptPayload::decode(&*at_deadline.payload).unwrap();
2565 assert_eq!(payload.handoff_id, "h1");
2566 assert_eq!(payload.accepted_by, "target");
2567 assert_eq!(payload.reason, IMPLICIT_ACCEPT_REASON);
2568 assert!(payload.implicit);
2569
2570 let mut paused = session.clone();
2573 paused.suspend(OFFER_TIME_MS + 50).unwrap();
2574 paused.resume(OFFER_TIME_MS + 200).unwrap();
2575 assert_eq!(paused.suspension_intervals, vec![(1_050, 1_200)]);
2576 assert!(mode
2577 .due_synthetic_envelope(&paused, OFFER_TIME_MS + 249)
2578 .is_none());
2579 assert_eq!(
2580 mode.due_synthetic_envelope(&paused, OFFER_TIME_MS + 250)
2581 .expect("due at the walked deadline")
2582 .timestamp_unix_ms,
2583 OFFER_TIME_MS + 250
2584 );
2585
2586 let mut late_pause = session.clone();
2593 late_pause.suspend(deadline + 50).unwrap();
2594 late_pause.resume(deadline + 250).unwrap();
2595 let observed_at = deadline + 400;
2596 let late = mode
2597 .due_synthetic_envelope(&late_pause, observed_at)
2598 .expect("a pause after the deadline cannot un-due the accept");
2599 assert_eq!(
2600 late.timestamp_unix_ms, deadline,
2601 "a pause beginning after D must not move D"
2602 );
2603 assert_ne!(
2606 late.timestamp_unix_ms, observed_at,
2607 "observation time is not the deadline"
2608 );
2609 assert_eq!(late_pause.accumulated_suspended_ms, 200);
2610 assert_ne!(
2611 late.timestamp_unix_ms,
2612 OFFER_TIME_MS + timeout + late_pause.accumulated_suspended_ms,
2613 "the naive offered_at + timeout + banked-suspension formula counts \
2614 pauses outside the window"
2615 );
2616 }
2617
2618 #[test]
2621 fn due_synthetic_envelope_returns_none_unless_an_offer_is_due() {
2622 let (mode, session) = offered_session();
2623 let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2624 let long_after = deadline + 1_000_000;
2625
2626 for rev in [0, 1] {
2630 let mut legacy = session.clone();
2631 legacy.semantics_rev = rev;
2632 assert!(
2633 mode.due_synthetic_envelope(&legacy, long_after).is_none(),
2634 "rev {rev} must never synthesize"
2635 );
2636 }
2637
2638 let mut unbound = session.clone();
2640 unbound.policy_definition = None;
2641 assert!(mode.due_synthetic_envelope(&unbound, long_after).is_none());
2642
2643 for rules in [
2647 serde_json::json!({ "acceptance": { "implicit_accept_timeout_ms": 0 } }),
2648 serde_json::json!({ "acceptance": "not an object" }),
2649 ] {
2650 let mut off = session.clone();
2651 off.policy_definition = Some(macp_core::policy::PolicyDefinition {
2652 policy_id: "off".into(),
2653 mode: "macp.mode.handoff.v1".into(),
2654 description: "no implicit accept".into(),
2655 rules,
2656 schema_version: 1,
2657 });
2658 assert_eq!(HandoffMode::implicit_accept_timeout_ms(&off), 0);
2659 assert!(mode.due_synthetic_envelope(&off, long_after).is_none());
2660 }
2661
2662 let mut no_offer = session.clone();
2664 no_offer.mode_state = HandoffMode::encode_state(&HandoffState::default());
2665 assert!(mode.due_synthetic_envelope(&no_offer, long_after).is_none());
2666 let mut empty = session.clone();
2667 empty.mode_state = vec![];
2668 assert!(mode.due_synthetic_envelope(&empty, long_after).is_none());
2669 let mut garbage = session.clone();
2670 garbage.mode_state = b"not json".to_vec();
2671 assert!(mode.due_synthetic_envelope(&garbage, long_after).is_none());
2672
2673 for disposition in [HandoffDisposition::Accepted, HandoffDisposition::Declined] {
2675 let mut settled = session.clone();
2676 let mut state: HandoffState = serde_json::from_slice(&settled.mode_state).unwrap();
2677 state.offers.get_mut("h1").unwrap().disposition = disposition;
2678 settled.mode_state = serde_json::to_vec(&state).unwrap();
2679 assert!(mode.due_synthetic_envelope(&settled, long_after).is_none());
2680 }
2681
2682 let mut clockless = session.clone();
2685 let mut state: HandoffState = serde_json::from_slice(&clockless.mode_state).unwrap();
2686 state.offers.get_mut("h1").unwrap().offered_at_ms = 0;
2687 clockless.mode_state = serde_json::to_vec(&state).unwrap();
2688 assert!(mode
2689 .due_synthetic_envelope(&clockless, long_after)
2690 .is_none());
2691 }
2692
2693 #[test]
2698 fn synthetic_payload_bytes_are_pinned() {
2699 let (mode, session) = offered_session();
2700 let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2701 let synthetic = mode
2702 .due_synthetic_envelope(&session, deadline)
2703 .expect("due at the deadline");
2704
2705 let expected: Vec<u8> = [
2706 b"\x0a\x02h1".as_slice(), b"\x12\x06target", b"\x1a\x19implicit accept (timeout)", b"\x20\x01", ]
2711 .concat();
2712 assert_eq!(synthetic.payload, expected);
2713 }
2714
2715 #[test]
2722 fn implicit_accept_dispatch_accepted_at_rev2() {
2723 let (mode, session) = offered_session();
2724 let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2725 let synthetic = mode
2726 .due_synthetic_envelope(&session, deadline)
2727 .expect("due at the deadline");
2728 let ctx = macp_core::mode::MessageContext::new(synthetic.timestamp_unix_ms);
2731
2732 let mut accepted = session.clone();
2733 let resp = mode.on_message_at(&accepted, &synthetic, &ctx).unwrap();
2734 apply(&mut accepted, resp);
2735 let state: HandoffState = serde_json::from_slice(&accepted.mode_state).unwrap();
2736 let offer = &state.offers["h1"];
2737 assert_eq!(offer.disposition, HandoffDisposition::Accepted);
2738 assert_eq!(offer.accepted_by.as_deref(), Some("target"));
2739 assert_eq!(
2740 offer.outcome_reason.as_deref(),
2741 Some(IMPLICIT_ACCEPT_REASON)
2742 );
2743
2744 let commit = mode
2746 .on_message_at(
2747 &accepted,
2748 &env("owner", "Commitment", commitment_payload()),
2749 &macp_core::mode::MessageContext::new(deadline + 10),
2750 )
2751 .unwrap();
2752 assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2753
2754 assert!(mode
2757 .due_synthetic_envelope(&accepted, deadline + 10_000)
2758 .is_none());
2759 assert!(matches!(
2760 mode.on_message_at(&accepted, &synthetic, &ctx).unwrap_err(),
2761 MacpError::InvalidPayload
2762 ));
2763
2764 let mut wrong_sender = synthetic.clone();
2767 wrong_sender.sender = "owner".into();
2768 assert!(matches!(
2769 mode.on_message_at(&session, &wrong_sender, &ctx)
2770 .unwrap_err(),
2771 MacpError::Forbidden
2772 ));
2773
2774 let mut wrong_accepted_by = synthetic.clone();
2775 wrong_accepted_by.payload = make_implicit_accept("h1", "owner");
2776 assert!(matches!(
2777 mode.on_message_at(&session, &wrong_accepted_by, &ctx)
2778 .unwrap_err(),
2779 MacpError::InvalidPayload
2780 ));
2781
2782 let mut empty_accepted_by = synthetic.clone();
2783 empty_accepted_by.payload = make_implicit_accept("h1", "");
2784 assert!(
2785 matches!(
2786 mode.on_message_at(&session, &empty_accepted_by, &ctx)
2787 .unwrap_err(),
2788 MacpError::InvalidPayload
2789 ),
2790 "stricter than the explicit arm: the synthetic always names the target"
2791 );
2792
2793 let mut wrong_id = synthetic.clone();
2794 wrong_id.message_id = "accept-1".into();
2795 assert!(matches!(
2796 mode.on_message_at(&session, &wrong_id, &ctx).unwrap_err(),
2797 MacpError::InvalidPayload
2798 ));
2799
2800 let mut unknown_offer = synthetic.clone();
2801 unknown_offer.message_id = reserved_id("h2");
2802 unknown_offer.payload = make_implicit_accept("h2", "target");
2803 assert!(matches!(
2804 mode.on_message_at(&session, &unknown_offer, &ctx)
2805 .unwrap_err(),
2806 MacpError::InvalidPayload
2807 ));
2808
2809 let mut legacy = session.clone();
2811 legacy.semantics_rev = 1;
2812 assert!(matches!(
2813 mode.on_message_at(&legacy, &synthetic, &ctx).unwrap_err(),
2814 MacpError::InvalidPayload
2815 ));
2816 }
2817
2818 #[test]
2834 fn implicit_accept_dispatch_does_not_reverify_the_deadline() {
2835 let (mode, session) = offered_session();
2836 let timeout = bound_timeout_ms(&session);
2837 let deadline = OFFER_TIME_MS + timeout;
2838 let synthetic = mode
2839 .due_synthetic_envelope(&session, deadline)
2840 .expect("due at the deadline");
2841 assert_eq!(synthetic.timestamp_unix_ms, deadline);
2842
2843 let mut replayed = session.clone();
2846 replayed.suspend(deadline + 50).unwrap();
2847 replayed.resume(deadline + 250).unwrap();
2848 assert_eq!(replayed.accumulated_suspended_ms, 200);
2849
2850 let state: HandoffState = serde_json::from_slice(&replayed.mode_state).unwrap();
2852 assert!(
2853 HandoffMode::implicit_accept_elapsed_ms(&replayed, &state.offers["h1"], deadline)
2854 < timeout,
2855 "the scalar is below the timeout here — that is the whole hazard"
2856 );
2857
2858 let ctx = macp_core::mode::MessageContext::new(synthetic.timestamp_unix_ms);
2861 let resp = mode.on_message_at(&replayed, &synthetic, &ctx).unwrap();
2862 apply(&mut replayed, resp);
2863 let state: HandoffState = serde_json::from_slice(&replayed.mode_state).unwrap();
2864 assert_eq!(
2865 state.offers["h1"].disposition,
2866 HandoffDisposition::Accepted,
2867 "a correctly-emitted synthetic entry must replay through dispatch"
2868 );
2869 }
2870}