Skip to main content

macp_modes/mode/
handoff.rs

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