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