Skip to main content

macp_modes/mode/
handoff.rs

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