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/// `message_id` namespace reserved for the runtime-synthesized implicit
17/// `HandoffAccept` (RFC-MACP-0010 §5.1(3), which fixes the id as
18/// `implicit-accept:<handoff_id>`).
19///
20/// At semantics rev >= 2 no client-submitted envelope in a handoff session may
21/// carry a `message_id` with this prefix, whatever its message type — see
22/// [`Mode::validate_client_envelope`]. Reserving the whole prefix (rather than
23/// the one exact id) keeps the failure loud: a client that squats the id a
24/// future offer would use consumes that dedup slot, after which the runtime's
25/// own synthesis would be silently skipped and the session could never reach a
26/// `Commitment`. The parties able to do it are the session's own initiator and
27/// the offerer, so this is fail-fast conformance, not attack mitigation.
28///
29/// The match is **case-sensitive**, which is sufficient rather than sloppy: the
30/// synthesized id is always built lowercase from this const plus the client's
31/// own `handoff_id`, so a differently-cased squat (`Implicit-Accept:h1`) can
32/// never collide with the id the runtime will later insert and so can never
33/// consume its dedup slot. It is accepted as an ordinary client id.
34pub const IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX: &str = "implicit-accept:";
35
36/// `reason` carried by the implicit accept, and therefore the offer's
37/// `outcome_reason` once it is applied.
38///
39/// **Byte-frozen.** It lives in serialized `mode_state`, so changing it breaks
40/// replay of every already-persisted rev <= 1 history that implicitly
41/// accepted — those logs were written by a binary carrying the old literal and
42/// cannot be rewritten. The guards that actually catch a change are the
43/// hand-built histories in `src/replay.rs`'s test module (eight of them) plus
44/// `synthetic_payload_bytes_are_pinned` below. Note it is **not**
45/// `assert_replay_equivalence` in `tests/conformance_loader.rs`, as this note
46/// first claimed: that compares live against replayed *within one binary*, so
47/// both sides move together and a changed literal slips through it. It is also
48/// what keeps the synthesized accept
49/// (RFC-MACP-0010 §5.1(2)) and the interim in-`Commitment` path below
50/// indistinguishable in `mode_state` — the reason both share this one const
51/// rather than repeating the literal.
52const IMPLICIT_ACCEPT_REASON: &str = "implicit accept (timeout)";
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub enum HandoffDisposition {
56    Offered,
57    Accepted,
58    Declined,
59}
60
61/// One handoff offer as it stands in the session's serialized `mode_state`.
62///
63/// **`#[non_exhaustive]`** (0.8.0, `DECISIONS.md` D7). This is persisted
64/// coordination state, and every release that teaches the handoff mode
65/// something new adds a field to it: 0.8.0 adds
66/// [`suspended_ms_at_offer`](Self::suspended_ms_at_offer), one release after
67/// [`offered_at_ms`](Self::offered_at_ms). With all-`pub` fields and no seal,
68/// each of those is a `constructible_struct_adds_field` major break against
69/// any external exhaustive struct literal — and
70/// `cargo semver-checks check-release --workspace --baseline-version 0.7.6`
71/// reports exactly that for `suspended_ms_at_offer`. `release-plz.toml`'s
72/// `semver_check = true` turns it into a blocked release PR for all seven
73/// lockstep crates. Sealing the struct once makes every future field
74/// additive.
75///
76/// Fields stay `pub` and readable; only construction by struct literal from
77/// another crate is refused. Within the workspace nothing changes. The
78/// records are produced by the mode from accepted envelopes — there is no
79/// supported way for a caller to mint one, which is why no constructor is
80/// offered in its place.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[non_exhaustive]
83pub struct HandoffOfferRecord {
84    pub handoff_id: String,
85    pub target_participant: String,
86    pub scope: String,
87    pub reason: String,
88    pub offered_by: String,
89    pub disposition: HandoffDisposition,
90    pub accepted_by: Option<String>,
91    pub declined_by: Option<String>,
92    pub outcome_reason: Option<String>,
93    #[serde(default)]
94    pub offered_at_ms: i64,
95    /// `Session::accumulated_suspended_ms` as it stood when the offer was
96    /// recorded — a snapshot, never a live read.
97    ///
98    /// RFC-MACP-0010 §5.1(1): time the session spends `Suspended` must not
99    /// count toward the implicit-accept deadline, and the suspension accrued
100    /// *since this offer* is `session.accumulated_suspended_ms` minus this
101    /// value. Both terms are on the recorded timeline (suspend/resume replay
102    /// from the log), so the subtraction is replay-deterministic.
103    ///
104    /// Legacy `mode_state` recorded before this field existed deserializes as
105    /// `0` (serde default), which is also the value for an offer made on a
106    /// session that had never been suspended.
107    #[serde(default)]
108    pub suspended_ms_at_offer: i64,
109}
110
111/// One `HandoffContext` message as it stands in serialized `mode_state`.
112///
113/// `#[non_exhaustive]` for the reason on [`HandoffOfferRecord`].
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[non_exhaustive]
116pub struct HandoffContextRecord {
117    pub content_type: String,
118    pub context: Vec<u8>,
119    pub sender: String,
120}
121
122/// The handoff mode's whole serialized `mode_state`.
123///
124/// `#[non_exhaustive]` for the reason on [`HandoffOfferRecord`]. `Default` is
125/// still derived and still reachable from other crates
126/// (`HandoffState::default()`), because `#[non_exhaustive]` refuses only the
127/// struct-literal form — including `HandoffState { ..Default::default() }`.
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129#[non_exhaustive]
130pub struct HandoffState {
131    pub offers: BTreeMap<String, HandoffOfferRecord>,
132    pub contexts: BTreeMap<String, Vec<HandoffContextRecord>>,
133}
134
135pub struct HandoffMode {
136    evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>,
137}
138
139impl HandoffMode {
140    /// Construct the mode with an injected governance policy evaluator.
141    pub fn new(evaluator: std::sync::Arc<dyn macp_core::policy::PolicyEvaluator>) -> Self {
142        Self { evaluator }
143    }
144
145    fn encode_state(state: &HandoffState) -> Vec<u8> {
146        crate::mode::util::encode_mode_state(state)
147    }
148
149    fn decode_state(data: &[u8]) -> Result<HandoffState, MacpError> {
150        crate::mode::util::decode_mode_state(data)
151    }
152
153    /// Elapsed time an outstanding offer's `implicit_accept_timeout_ms` is
154    /// measured against, selected by the session's semantics revision.
155    ///
156    /// Rev <= 1 keeps the raw difference verbatim — suspended time included —
157    /// so legacy histories replay to the outcome they were accepted with, even
158    /// though that outcome violates RFC-MACP-0010 §5.1(1). The correction is
159    /// deliberately gated rather than applied to every revision: a history is
160    /// only replayable under the semantics it was accepted with, and
161    /// retroactively un-accepting an offer a rev-1 session already committed on
162    /// would make the log unreplayable. Rev >= 2 gets the corrected deadline;
163    /// see [`Self::rev2_elapsed_ms`].
164    fn implicit_accept_elapsed_ms(
165        session: &Session,
166        offer: &HandoffOfferRecord,
167        now_ms: i64,
168    ) -> i64 {
169        if session.semantics_rev >= 2 {
170            Self::rev2_elapsed_ms(session, offer, now_ms)
171        } else {
172            // Deliberately non-saturating, unlike the rev >= 2 arm. Rev 0
173            // reads `env.timestamp_unix_ms`, which the server boundary never
174            // range-checks, so this subtraction can overflow in principle —
175            // but retrofitting saturation here would change rev 0/1 outcomes,
176            // which must be preserved exactly. It also adds nothing to the
177            // attack surface: rev 0 already lets a client forge elapsed time
178            // directly by back-dating the offer envelope, which is the defect
179            // rev 1 fixed and rev 0 intentionally keeps.
180            now_ms - offer.offered_at_ms
181        }
182    }
183
184    /// The rev >= 2 elapsed computation: time since the offer, minus the time
185    /// the session spent `Suspended` within that window (RFC-MACP-0010
186    /// §5.1(1) — a suspended session must not tick toward the implicit-accept
187    /// deadline).
188    ///
189    /// Both terms are on the recorded timeline — `offered_at_ms` is the
190    /// acceptance clock, and `accumulated_suspended_ms` is banked from the
191    /// recorded `received_at_ms` of the suspend/resume entries — so the result
192    /// is replay-deterministic.
193    ///
194    /// `accumulated_suspended_ms` alone is complete here: there is
195    /// deliberately **no** in-flight `now_ms - suspended_at_ms` term, because
196    /// this code only ever runs while the session is `Open`, so every pause
197    /// that has occurred is already banked. `Session::resume` is the only
198    /// writer that ever *increases* `accumulated_suspended_ms` after
199    /// construction — the other writer, `SessionBuilder`, only sets it at
200    /// construction time from already-banked persisted state (snapshot and
201    /// checkpoint loads go through `From<PersistedSession> for Session`) — and
202    /// both paths that reach this function refuse to dispatch a message to a
203    /// non-`Open` session:
204    /// `crate::step::check_preconditions` returns `SessionNotOpen` before the
205    /// kernel calls `on_message_at`, and replay skips Incoming entries whose
206    /// session is not `Open` (`src/replay.rs`). An in-flight term would
207    /// therefore be untestable dead code — see `Session::suspend_cap_exceeded`
208    /// for the shape it must take if a *future* caller can observe a suspended
209    /// session (e.g. an eager sweep running outside the message path).
210    ///
211    /// Arithmetic is saturating and the suspension term is floored at zero.
212    /// The floor cannot trigger from runtime-written state — the snapshot is
213    /// taken from the same monotonically non-decreasing counter this reads —
214    /// but if corrupted or hand-edited persisted state ever made the term
215    /// negative, adding it back would *inflate* elapsed time and implicitly
216    /// accept an offer the target never accepted. Flooring degrades to the
217    /// rev-1 arithmetic instead, which is the conservative direction.
218    fn rev2_elapsed_ms(session: &Session, offer: &HandoffOfferRecord, now_ms: i64) -> i64 {
219        let suspended_since_offer = session
220            .accumulated_suspended_ms
221            .saturating_sub(offer.suspended_ms_at_offer)
222            .max(0);
223        now_ms
224            .saturating_sub(offer.offered_at_ms)
225            .saturating_sub(suspended_since_offer)
226    }
227
228    /// The bound governance policy's `acceptance.implicit_accept_timeout_ms`,
229    /// or `0` when no policy is bound or its rules do not parse.
230    ///
231    /// `0` means implicit accept never fires, so an unparseable rules document
232    /// degrades to "no implicit accept" rather than to some default timeout.
233    /// This mirrors the interim in-`Commitment` path's `unwrap_or_default()`
234    /// exactly, deliberately: until that path is retired the two must agree, or
235    /// a session could implicitly accept through one and not the other.
236    fn implicit_accept_timeout_ms(session: &Session) -> i64 {
237        let Some(policy) = session.policy_definition.as_ref() else {
238            return 0;
239        };
240        let rules: macp_core::policy::rules::HandoffPolicyRules =
241            serde_json::from_value(policy.rules.clone()).unwrap_or_default();
242        rules.acceptance.implicit_accept_timeout_ms as i64
243    }
244
245    fn commitment_ready(state: &HandoffState) -> bool {
246        state.offers.values().any(|offer| {
247            offer.disposition == HandoffDisposition::Accepted
248                || offer.disposition == HandoffDisposition::Declined
249        })
250    }
251}
252
253impl Mode for HandoffMode {
254    /// RFC-MACP-0010 §5.1(3): a client-submitted `HandoffAccept` carrying
255    /// `implicit = true` MUST be rejected, and the synthetic accept's
256    /// `message_id` namespace is reserved.
257    ///
258    /// Gated to `semantics_rev >= 2` so rev <= 1 wire behavior is
259    /// byte-identical: a legacy session's client could send an
260    /// `implicit-accept:`-prefixed id and be accepted, and its history must
261    /// stay replayable under the semantics it was accepted with.
262    ///
263    /// Two rules, in this order:
264    /// 1. any `message_id` in the reserved namespace -> `InvalidEnvelope`
265    ///    (checked first, and for every message type: the squat works through
266    ///    `SessionStart`, `Commitment` and `HandoffContext` too);
267    /// 2. `HandoffAccept` whose payload decodes with `implicit = true` ->
268    ///    `InvalidPayload` — the same code `handle_message` returns for the
269    ///    same envelope today, so the rev-2 error surface does not shift.
270    ///
271    /// Rule 2 is what the mode itself will *stop* being able to enforce once
272    /// the runtime synthesizes implicit accepts: at rev >= 2 a well-formed
273    /// implicit accept with the deterministic `message_id` is exactly what
274    /// dispatch must accept on replay, and the mode cannot tell client
275    /// provenance from runtime provenance. This boundary can.
276    fn validate_client_envelope(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
277        if session.semantics_rev < 2 {
278            return Ok(());
279        }
280        if env
281            .message_id
282            .starts_with(IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX)
283        {
284            return Err(MacpError::InvalidEnvelope);
285        }
286        if env.message_type == "HandoffAccept" {
287            // A payload that does not decode is left to `handle_message`,
288            // which rejects it `InvalidPayload` on the same grounds. Deciding
289            // it here would only duplicate that.
290            if let Ok(payload) = HandoffAcceptPayload::decode(&*env.payload) {
291                if payload.implicit {
292                    return Err(MacpError::InvalidPayload);
293                }
294            }
295        }
296        Ok(())
297    }
298
299    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
300        match env.message_type.as_str() {
301            "Commitment" => check_commitment_authority(session, &env.sender),
302            // HandoffOffer: only initiator can offer
303            "HandoffOffer" if env.sender == session.initiator_sender => Ok(()),
304            "HandoffOffer" => Err(MacpError::Forbidden),
305            // HandoffContext: any declared participant (on_message enforces offerer match)
306            _ if is_declared_participant(&session.participants, &env.sender) => Ok(()),
307            _ => Err(MacpError::Forbidden),
308        }
309    }
310
311    fn on_session_start(
312        &self,
313        session: &Session,
314        _env: &Envelope,
315    ) -> Result<ModeResponse, MacpError> {
316        // RFC-MACP-0010 §2 (delegated model): the accepted SessionStart
317        // sender IS the current responsibility owner, and §3 binds
318        // `participants` as "current owner and eligible targets". Both checks
319        // below are stricter than the literal §3 text but follow from the
320        // model: the owner must be in the list, alongside ≥1 eligible target.
321        // (Unlike Task/Decision/Quorum, initiator membership is intrinsic
322        // here — the initiator is a transfer party, not just a coordinator.)
323        if session.participants.len() < 2 {
324            return Err(MacpError::InvalidPayload);
325        }
326        if !session
327            .participants
328            .iter()
329            .any(|p| p == &session.initiator_sender)
330        {
331            return Err(MacpError::InvalidPayload);
332        }
333        Ok(ModeResponse::PersistState(Self::encode_state(
334            &HandoffState::default(),
335        )))
336    }
337
338    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
339        // Legacy clock (semantics rev 0): the client-supplied envelope
340        // timestamp. The kernel calls `on_message_at`, which selects the
341        // acceptance clock for rev >= 1 sessions; this path remains for
342        // legacy-history replay and direct library callers.
343        self.handle_message(session, env, env.timestamp_unix_ms)
344    }
345
346    fn on_message_at(
347        &self,
348        session: &Session,
349        env: &Envelope,
350        ctx: &macp_core::mode::MessageContext,
351    ) -> Result<ModeResponse, MacpError> {
352        // Rev >= 1: the implicit-accept timeout is measured against the
353        // runtime's acceptance clock, which the initiator cannot forge (the
354        // envelope timestamp let an initiator post-date a Commitment to
355        // finalize an offer the target never accepted). Legacy (rev 0)
356        // sessions keep the envelope clock so their histories replay to the
357        // same outcome they were accepted with.
358        let clock_ms = if session.semantics_rev >= 1 {
359            ctx.accepted_at_ms
360        } else {
361            env.timestamp_unix_ms
362        };
363        self.handle_message(session, env, clock_ms)
364    }
365
366    /// RFC-MACP-0010 §5.1(2)-(3): the synthetic implicit `HandoffAccept`, due
367    /// once the outstanding offer's `implicit_accept_timeout_ms` has elapsed in
368    /// *unsuspended* session time.
369    ///
370    /// Gated to `semantics_rev >= 2`. Rev <= 1 sessions get `None` forever and
371    /// keep resolving through the interim in-`Commitment` path, so their
372    /// histories replay to the outcome they were accepted with.
373    ///
374    /// # Decision vs. timestamp — two different functions, on purpose
375    ///
376    /// The **decision** ("has the timeout elapsed?") is the scalar
377    /// `implicit_accept_elapsed_ms`, which is exact for this purpose:
378    /// `elapsed(T) >= timeout` iff `T >= D`, because every pause banked since
379    /// the offer lies inside `[offered_at, T]`.
380    ///
381    /// Only the **timestamp** needs the interval walk
382    /// [`Session::unsuspended_deadline`]. §5.1(3) fixes it at "offer acceptance
383    /// time + timeout + suspended time *within the window*", which is **not**
384    /// the naive `offered_at + timeout + banked_since_offer`: that form counts
385    /// pauses beginning *after* the deadline — fully reachable, since
386    /// `SuspendSession`/`ResumeSession` are RPCs and need no session-scoped
387    /// message — and it would make a permanently-recorded timestamp depend on
388    /// when it happened to be observed.
389    ///
390    /// The two are consistent in the safe direction: an under-recorded
391    /// `suspension_intervals` vec (see `unsuspended_deadline`'s under-count
392    /// invariant) can only move `D` earlier, never later, so the
393    /// `debug_assert!(D <= now_ms)` below holds even on a legacy rev-2
394    /// snapshot.
395    ///
396    /// # Cost
397    ///
398    /// One `mode_state` decode per call for handoff sessions with a bound
399    /// timeout, and an allocation only when an envelope is actually due. A
400    /// cached "next deadline" flag on the session was considered and rejected
401    /// as premature: it would need a writer on the resume path, which is not
402    /// mode-dispatched at all.
403    fn due_synthetic_envelope(&self, session: &Session, now_ms: i64) -> Option<Envelope> {
404        if session.semantics_rev < 2 {
405            return None;
406        }
407        let timeout = Self::implicit_accept_timeout_ms(session);
408        if timeout <= 0 {
409            return None;
410        }
411        if session.mode_state.is_empty() {
412            return None;
413        }
414        let state = Self::decode_state(&session.mode_state).ok()?;
415        // RFC-MACP-0010 §5(5): at most one offer is ever outstanding, and once
416        // one is accepted no further offers may be issued — hence `Option`, not
417        // a queue. `values()` is a `BTreeMap` walk, so even a corrupted state
418        // with two outstanding offers picks deterministically.
419        let offer = state
420            .offers
421            .values()
422            .find(|offer| offer.disposition == HandoffDisposition::Offered)?;
423        // Unreachable at rev >= 2 (rev >= 1 records the acceptance clock on
424        // every offer), but kept: it costs nothing, the interim path has the
425        // same guard, and a zero here would otherwise measure the timeout from
426        // the epoch.
427        if offer.offered_at_ms <= 0 {
428            return None;
429        }
430        if Self::implicit_accept_elapsed_ms(session, offer, now_ms) < timeout {
431            return None;
432        }
433        // Completed pauses only: an in-progress suspension is not in
434        // `suspension_intervals`, and a suspended session ticks no unsuspended
435        // time anyway. Kernel callers must skip non-`Open` sessions.
436        debug_assert!(
437            session.suspended_at_ms.is_none(),
438            "due_synthetic_envelope must not be asked about a suspended session"
439        );
440        let deadline = session.unsuspended_deadline(offer.offered_at_ms, timeout);
441        debug_assert!(
442            deadline <= now_ms,
443            "deadline {deadline} is in the future of the observation {now_ms}"
444        );
445        Some(Envelope {
446            macp_version: "1.0".into(),
447            mode: session.mode.clone(),
448            message_type: "HandoffAccept".into(),
449            message_id: format!(
450                "{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{}",
451                offer.handoff_id.as_str()
452            ),
453            session_id: session.session_id.clone(),
454            sender: offer.target_participant.clone(),
455            // §5.1(3) SHOULD, adopted here as a MUST: the recorded clocks are
456            // the computed deadline, never the observation time.
457            timestamp_unix_ms: deadline,
458            payload: HandoffAcceptPayload {
459                handoff_id: offer.handoff_id.clone(),
460                accepted_by: offer.target_participant.clone(),
461                reason: IMPLICIT_ACCEPT_REASON.into(),
462                implicit: true,
463            }
464            .encode_to_vec(),
465        })
466    }
467}
468
469impl HandoffMode {
470    fn handle_message(
471        &self,
472        session: &Session,
473        env: &Envelope,
474        clock_ms: i64,
475    ) -> Result<ModeResponse, MacpError> {
476        let mut state = if session.mode_state.is_empty() {
477            HandoffState::default()
478        } else {
479            Self::decode_state(&session.mode_state)?
480        };
481
482        match env.message_type.as_str() {
483            "HandoffOffer" => {
484                let payload = HandoffOfferPayload::decode(&*env.payload)
485                    .map_err(|_| MacpError::InvalidPayload)?;
486                // RFC-MACP-0010: At most one offer may be outstanding at any time.
487                // Once an offer is accepted, no further offers may be issued.
488                if payload.handoff_id.is_empty()
489                    || payload.target_participant.is_empty()
490                    || state.offers.contains_key(&payload.handoff_id)
491                    || !is_declared_participant(&session.participants, &payload.target_participant)
492                    || payload.target_participant == env.sender
493                    || state
494                        .offers
495                        .values()
496                        .any(|o| o.disposition == HandoffDisposition::Offered)
497                    || state
498                        .offers
499                        .values()
500                        .any(|o| o.disposition == HandoffDisposition::Accepted)
501                {
502                    return Err(MacpError::InvalidPayload);
503                }
504                state.offers.insert(
505                    payload.handoff_id.clone(),
506                    HandoffOfferRecord {
507                        handoff_id: payload.handoff_id,
508                        target_participant: payload.target_participant,
509                        scope: payload.scope,
510                        reason: payload.reason,
511                        offered_by: env.sender.clone(),
512                        disposition: HandoffDisposition::Offered,
513                        accepted_by: None,
514                        declined_by: None,
515                        outcome_reason: None,
516                        // Rev >= 1: record the runtime acceptance clock (the
517                        // same value the log entry records, so replay is
518                        // identical). The client envelope timestamp is
519                        // unvalidated — recording it here let an offering
520                        // participant BACK-date the offer and immediately
521                        // commit, forging elapsed time past the implicit-
522                        // accept timeout (the same attack as post-dating the
523                        // commitment, relocated to the offer side).
524                        offered_at_ms: if session.semantics_rev >= 1 {
525                            clock_ms
526                        } else {
527                            env.timestamp_unix_ms
528                        },
529                        // Recorded unconditionally (it is read only under the
530                        // rev >= 2 branch of `implicit_accept_elapsed_ms`, so
531                        // recording it on every session is behavior-neutral
532                        // and keeps the field trustworthy wherever it is
533                        // later consulted).
534                        suspended_ms_at_offer: session.accumulated_suspended_ms,
535                    },
536                );
537                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
538            }
539            "HandoffContext" => {
540                let payload = HandoffContextPayload::decode(&*env.payload)
541                    .map_err(|_| MacpError::InvalidPayload)?;
542                let offer = state
543                    .offers
544                    .get(&payload.handoff_id)
545                    .ok_or(MacpError::InvalidPayload)?;
546                if offer.offered_by != env.sender {
547                    return Err(MacpError::Forbidden);
548                }
549                // RFC-MACP-0010 §2.1: Late context (sent after accept/decline) is
550                // permitted as supplementary documentation. No disposition check.
551                state
552                    .contexts
553                    .entry(payload.handoff_id)
554                    .or_default()
555                    .push(HandoffContextRecord {
556                        content_type: payload.content_type,
557                        context: payload.context,
558                        sender: env.sender.clone(),
559                    });
560                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
561            }
562            "HandoffAccept" => {
563                let payload = HandoffAcceptPayload::decode(&*env.payload)
564                    .map_err(|_| MacpError::InvalidPayload)?;
565                if payload.implicit {
566                    return self.dispatch_implicit_accept(session, env, payload, state);
567                }
568                let offer = state
569                    .offers
570                    .get_mut(&payload.handoff_id)
571                    .ok_or(MacpError::InvalidPayload)?;
572                if offer.target_participant != env.sender {
573                    return Err(MacpError::Forbidden);
574                }
575                if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender {
576                    return Err(MacpError::InvalidPayload);
577                }
578                if offer.disposition != HandoffDisposition::Offered {
579                    return Err(MacpError::InvalidPayload);
580                }
581                offer.disposition = HandoffDisposition::Accepted;
582                offer.accepted_by = Some(env.sender.clone());
583                offer.outcome_reason = Some(payload.reason);
584                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
585            }
586            "HandoffDecline" => {
587                let payload = HandoffDeclinePayload::decode(&*env.payload)
588                    .map_err(|_| MacpError::InvalidPayload)?;
589                let offer = state
590                    .offers
591                    .get_mut(&payload.handoff_id)
592                    .ok_or(MacpError::InvalidPayload)?;
593                if offer.target_participant != env.sender {
594                    return Err(MacpError::Forbidden);
595                }
596                if !payload.declined_by.is_empty() && payload.declined_by != env.sender {
597                    return Err(MacpError::InvalidPayload);
598                }
599                if offer.disposition != HandoffDisposition::Offered {
600                    return Err(MacpError::InvalidPayload);
601                }
602                offer.disposition = HandoffDisposition::Declined;
603                offer.declined_by = Some(env.sender.clone());
604                offer.outcome_reason = Some(payload.reason);
605                Ok(ModeResponse::PersistState(Self::encode_state(&state)))
606            }
607            "Commitment" => {
608                let commitment = validate_commitment_payload_for_session(session, &env.payload)?;
609                // RFC-MACP-0012: lazy implicit_accept_timeout_ms check
610                if let Some(ref policy) = session.policy_definition {
611                    let rules: macp_core::policy::rules::HandoffPolicyRules =
612                        serde_json::from_value(policy.rules.clone()).unwrap_or_default();
613                    if rules.acceptance.implicit_accept_timeout_ms > 0 {
614                        // Clock selected by `on_message_at` per the session's
615                        // semantics revision: acceptance time (rev >= 1) or the
616                        // legacy envelope timestamp (rev 0). Both are
617                        // log-recorded, so replay is deterministic either way.
618                        let now_ms = clock_ms;
619                        let timeout = rules.acceptance.implicit_accept_timeout_ms as i64;
620                        // The interim path, retired at rev >= 2. From rev 2 the
621                        // kernel synthesizes the accept into accepted history
622                        // (`Runtime::synthesize_due_accept`) before this
623                        // `Commitment` is ever dispatched, so the offer is
624                        // already `Accepted` in `state` by the time we get
625                        // here; applying it a second time here would make the
626                        // live session's `mode_state` depend on a mutation
627                        // replay cannot reproduce.
628                        //
629                        // The consequence at rev >= 2 is deliberately
630                        // fail-loud: a `Commitment` on a history that *lacks*
631                        // the synthetic entry now falls through to
632                        // `commitment_ready` below and is rejected
633                        // `InvalidPayload`. Leaving the interim active would
634                        // instead let replay of a foreign or buggy rev-2 log
635                        // silently resolve — hiding exactly the divergence
636                        // this revision exists to make impossible.
637                        if session.semantics_rev < 2 {
638                            for offer in state.offers.values_mut() {
639                                if offer.disposition == HandoffDisposition::Offered
640                                    && offer.offered_at_ms > 0
641                                    && Self::implicit_accept_elapsed_ms(session, offer, now_ms)
642                                        >= timeout
643                                {
644                                    offer.disposition = HandoffDisposition::Accepted;
645                                    offer.accepted_by = Some(offer.target_participant.clone());
646                                    offer.outcome_reason = Some(IMPLICIT_ACCEPT_REASON.into());
647                                }
648                            }
649                        }
650                    }
651                }
652                if !Self::commitment_ready(&state) {
653                    return Err(MacpError::InvalidPayload);
654                }
655                // Governance policy gate (shared): fail closed, only
656                // an explicit Allow proceeds.
657                enforce_commitment_policy(
658                    session,
659                    macp_core::policy::CommitmentMode::Handoff,
660                    commitment.outcome_positive,
661                    &*self.evaluator,
662                )?;
663                Ok(ModeResponse::PersistAndResolve {
664                    state: Self::encode_state(&state),
665                    resolution: env.payload.clone(),
666                })
667            }
668            _ => Err(MacpError::InvalidPayload),
669        }
670    }
671
672    /// The `HandoffAccept` arm for a payload carrying `implicit = true`.
673    ///
674    /// **Rev <= 1: reject, unconditionally** — RFC-MACP-0010 §5.1(3), and
675    /// today's behavior preserved verbatim so legacy histories replay
676    /// bit-identically. No rev <= 1 log can contain such an entry, because no
677    /// rev <= 1 runtime ever accepted or synthesized one.
678    ///
679    /// **Rev >= 2: validate strictly and accept.** The runtime synthesizes this
680    /// message into accepted history itself (§5.1(2)), so dispatch MUST accept
681    /// the well-formed shape — on replay of the recorded entry, and on the live
682    /// emission path. The mode cannot keep clients out here, because it cannot
683    /// tell client provenance from runtime provenance; that is
684    /// [`Mode::validate_client_envelope`]'s job (§5.1(3) scopes the client
685    /// prohibition to submission "via `Send`").
686    ///
687    /// # This arm MUST NOT re-verify the deadline arithmetic
688    ///
689    /// It validates identity and shape only — never time. On replay the entry
690    /// is dispatched with `ctx.accepted_at_ms = received_at_ms = D`, the
691    /// deadline it was emitted at, but `session.accumulated_suspended_ms` at
692    /// that point in the replay already includes pauses that happened *after*
693    /// D: the `SessionSuspend`/`SessionResume` entries between D and the
694    /// message that triggered the synthesis sit **earlier in the log** than the
695    /// synthetic entry does (its `received_at_ms` is D, which is why the log's
696    /// `received_at_ms` is locally non-monotonic in exactly this case — nothing
697    /// orders by it, replay and ordinals are positional). So re-checking
698    /// `implicit_accept_elapsed_ms(session, offer, D) >= timeout` would subtract
699    /// suspension from outside the window, compute *less* unsuspended time than
700    /// the timeout, and reject an entry that was emitted correctly — failing
701    /// replay of a valid log.
702    ///
703    /// [`Self::due_synthetic_envelope`] is the single place the timeout is
704    /// decided, once, at emission. Do not add a time check here.
705    fn dispatch_implicit_accept(
706        &self,
707        session: &Session,
708        env: &Envelope,
709        payload: HandoffAcceptPayload,
710        mut state: HandoffState,
711    ) -> Result<ModeResponse, MacpError> {
712        if session.semantics_rev < 2 {
713            return Err(MacpError::InvalidPayload);
714        }
715        let offer = state
716            .offers
717            .get_mut(&payload.handoff_id)
718            .ok_or(MacpError::InvalidPayload)?;
719        // Same code as the explicit arm for the same condition, so the error
720        // surface does not depend on the flag.
721        if offer.target_participant != env.sender {
722            return Err(MacpError::Forbidden);
723        }
724        // Stricter than the explicit arm, which tolerates an empty
725        // `accepted_by`: the synthetic envelope always names the target
726        // explicitly, so anything else is not the envelope this runtime emits.
727        if payload.accepted_by != offer.target_participant {
728            return Err(MacpError::InvalidPayload);
729        }
730        // The deterministic id from §5.1(3). Reserved at the client boundary at
731        // rev >= 2, so an envelope that reaches here carrying it is either
732        // replayed history or the kernel's own synthesis.
733        if env.message_id
734            != format!(
735                "{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{}",
736                offer.handoff_id.as_str()
737            )
738        {
739            return Err(MacpError::InvalidPayload);
740        }
741        if offer.disposition != HandoffDisposition::Offered {
742            return Err(MacpError::InvalidPayload);
743        }
744        // Exactly the mutation an explicit accept applies, and exactly the one
745        // the interim in-`Commitment` path applies — so a history carrying the
746        // synthetic entry rebuilds byte-identical `mode_state`.
747        //
748        // That byte-identity is guaranteed by `due_synthetic_envelope`, which
749        // is what supplies `IMPLICIT_ACCEPT_REASON`; this arm takes whatever
750        // `reason` the payload carries, because RFC-MACP-0010 makes the
751        // payload authoritative. A direct library caller at rev >= 2 can
752        // therefore hand-build a well-formed implicit accept with some other
753        // `reason` and land non-canonical `mode_state`. Unreachable through
754        // the wire (the 11c client boundary refuses the reserved id), and not
755        // this arm's job to police.
756        offer.disposition = HandoffDisposition::Accepted;
757        offer.accepted_by = Some(offer.target_participant.clone());
758        offer.outcome_reason = Some(payload.reason);
759        Ok(ModeResponse::PersistState(Self::encode_state(&state)))
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use macp_core::session::Session;
767    use macp_pb::pb::CommitmentPayload;
768
769    fn base_session() -> Session {
770        Session::builder("s1", "macp.mode.handoff.v1", "owner")
771            .ttl_ms(60_000)
772            .participants(vec!["owner".into(), "target".into()])
773            .mode_version("1.0.0")
774            .configuration_version("config")
775            .policy_version("policy")
776            .build()
777    }
778
779    fn env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
780        Envelope {
781            macp_version: "1.0".into(),
782            mode: "macp.mode.handoff.v1".into(),
783            message_type: message_type.into(),
784            message_id: format!("{}-{}", sender, message_type),
785            session_id: "s1".into(),
786            sender: sender.into(),
787            timestamp_unix_ms: chrono::Utc::now().timestamp_millis(),
788            payload,
789        }
790    }
791
792    fn commitment_payload() -> Vec<u8> {
793        CommitmentPayload {
794            commitment_id: "c1".into(),
795            action: "handoff.accepted".into(),
796            authority_scope: "support".into(),
797            reason: "accepted".into(),
798            mode_version: "1.0.0".into(),
799            policy_version: "policy".into(),
800            configuration_version: "config".into(),
801            outcome_positive: true,
802            supersedes: None,
803        }
804        .encode_to_vec()
805    }
806
807    fn apply(session: &mut Session, result: ModeResponse) {
808        match result {
809            ModeResponse::PersistState(data) => session.mode_state = data,
810            ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state,
811            _ => {}
812        }
813    }
814
815    fn make_offer(handoff_id: &str, target: &str) -> Vec<u8> {
816        HandoffOfferPayload {
817            handoff_id: handoff_id.into(),
818            target_participant: target.into(),
819            scope: "support".into(),
820            reason: "escalate".into(),
821        }
822        .encode_to_vec()
823    }
824
825    fn make_context(handoff_id: &str) -> Vec<u8> {
826        HandoffContextPayload {
827            handoff_id: handoff_id.into(),
828            content_type: "text/plain".into(),
829            context: b"background info".to_vec(),
830        }
831        .encode_to_vec()
832    }
833
834    fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
835        HandoffAcceptPayload {
836            handoff_id: handoff_id.into(),
837            accepted_by: accepted_by.into(),
838            reason: "ready".into(),
839            implicit: false,
840        }
841        .encode_to_vec()
842    }
843
844    fn make_decline(handoff_id: &str, declined_by: &str) -> Vec<u8> {
845        HandoffDeclinePayload {
846            handoff_id: handoff_id.into(),
847            declined_by: declined_by.into(),
848            reason: "busy".into(),
849        }
850        .encode_to_vec()
851    }
852
853    // --- Session Start ---
854
855    #[test]
856    fn session_start_initializes_state() {
857        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
858        let session = base_session();
859        let result = mode
860            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
861            .unwrap();
862        match result {
863            ModeResponse::PersistState(data) => {
864                let state: HandoffState = serde_json::from_slice(&data).unwrap();
865                assert!(state.offers.is_empty());
866            }
867            _ => panic!("Expected PersistState"),
868        }
869    }
870
871    #[test]
872    fn session_start_requires_two_participants() {
873        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
874        let mut session = base_session();
875        session.participants = vec!["owner".into()]; // only 1
876        let err = mode
877            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
878            .unwrap_err();
879        assert_eq!(err.to_string(), "InvalidPayload");
880    }
881
882    #[test]
883    fn session_start_rejects_when_initiator_not_participant() {
884        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
885        let mut session = base_session();
886        session.participants = vec!["target".into(), "other".into()]; // owner not included
887        let err = mode
888            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
889            .unwrap_err();
890        assert_eq!(err.to_string(), "InvalidPayload");
891    }
892
893    // --- HandoffOffer ---
894
895    #[test]
896    fn offer_creates_entry() {
897        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
898        let mut session = base_session();
899        let result = mode
900            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
901            .unwrap();
902        apply(&mut session, result);
903        let result = mode
904            .on_message(
905                &session,
906                &env("owner", "HandoffOffer", make_offer("h1", "target")),
907            )
908            .unwrap();
909        match result {
910            ModeResponse::PersistState(data) => {
911                let state: HandoffState = serde_json::from_slice(&data).unwrap();
912                assert!(state.offers.contains_key("h1"));
913                assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered);
914            }
915            _ => panic!("Expected PersistState"),
916        }
917    }
918
919    #[test]
920    fn duplicate_offer_id_rejected() {
921        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
922        let mut session = base_session();
923        let result = mode
924            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
925            .unwrap();
926        apply(&mut session, result);
927        let result = mode
928            .on_message(
929                &session,
930                &env("owner", "HandoffOffer", make_offer("h1", "target")),
931            )
932            .unwrap();
933        apply(&mut session, result);
934        let err = mode
935            .on_message(
936                &session,
937                &env("owner", "HandoffOffer", make_offer("h1", "target")),
938            )
939            .unwrap_err();
940        assert_eq!(err.to_string(), "InvalidPayload");
941    }
942
943    #[test]
944    fn offer_to_self_rejected() {
945        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
946        let mut session = base_session();
947        let result = mode
948            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
949            .unwrap();
950        apply(&mut session, result);
951        let err = mode
952            .on_message(
953                &session,
954                &env("owner", "HandoffOffer", make_offer("h1", "owner")),
955            )
956            .unwrap_err();
957        assert_eq!(err.to_string(), "InvalidPayload");
958    }
959
960    #[test]
961    fn offer_to_non_participant_rejected() {
962        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
963        let mut session = base_session();
964        let result = mode
965            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
966            .unwrap();
967        apply(&mut session, result);
968        let err = mode
969            .on_message(
970                &session,
971                &env("owner", "HandoffOffer", make_offer("h1", "outsider")),
972            )
973            .unwrap_err();
974        assert_eq!(err.to_string(), "InvalidPayload");
975    }
976
977    // --- HandoffContext ---
978
979    #[test]
980    fn context_for_existing_offer() {
981        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
982        let mut session = base_session();
983        let result = mode
984            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
985            .unwrap();
986        apply(&mut session, result);
987        let result = mode
988            .on_message(
989                &session,
990                &env("owner", "HandoffOffer", make_offer("h1", "target")),
991            )
992            .unwrap();
993        apply(&mut session, result);
994        let result = mode
995            .on_message(
996                &session,
997                &env("owner", "HandoffContext", make_context("h1")),
998            )
999            .unwrap();
1000        match result {
1001            ModeResponse::PersistState(data) => {
1002                let state: HandoffState = serde_json::from_slice(&data).unwrap();
1003                assert_eq!(state.contexts["h1"].len(), 1);
1004                assert_eq!(state.contexts["h1"][0].content_type, "text/plain");
1005                assert_eq!(state.contexts["h1"][0].sender, "owner");
1006            }
1007            _ => panic!("Expected PersistState"),
1008        }
1009    }
1010
1011    #[test]
1012    fn context_from_non_offerer_rejected() {
1013        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1014        let mut session = base_session();
1015        let result = mode
1016            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1017            .unwrap();
1018        apply(&mut session, result);
1019        let result = mode
1020            .on_message(
1021                &session,
1022                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1023            )
1024            .unwrap();
1025        apply(&mut session, result);
1026        let err = mode
1027            .on_message(
1028                &session,
1029                &env("target", "HandoffContext", make_context("h1")),
1030            )
1031            .unwrap_err();
1032        assert_eq!(err.to_string(), "Forbidden");
1033    }
1034
1035    // --- HandoffAccept / HandoffDecline ---
1036
1037    #[test]
1038    fn target_can_accept() {
1039        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1040        let mut session = base_session();
1041        let result = mode
1042            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1043            .unwrap();
1044        apply(&mut session, result);
1045        let result = mode
1046            .on_message(
1047                &session,
1048                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1049            )
1050            .unwrap();
1051        apply(&mut session, result);
1052        let result = mode
1053            .on_message(
1054                &session,
1055                &env("target", "HandoffAccept", make_accept("h1", "target")),
1056            )
1057            .unwrap();
1058        match result {
1059            ModeResponse::PersistState(data) => {
1060                let state: HandoffState = serde_json::from_slice(&data).unwrap();
1061                assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1062            }
1063            _ => panic!("Expected PersistState"),
1064        }
1065    }
1066
1067    /// A `HandoffAccept` carrying `implicit = true` under a `message_id`
1068    /// **outside** the reserved namespace is rejected `InvalidPayload`.
1069    ///
1070    /// This test used to be named `client_submitted_implicit_accept_is_rejected`
1071    /// and claimed to prove RFC-MACP-0010 §5.1(3)'s client prohibition. Since
1072    /// 11d it does not: at `semantics_rev >= 2` dispatch *must* accept a
1073    /// well-formed implicit accept (it is what replay feeds back in), so what
1074    /// still fails here is the `message_id` check — the `env()` helper produces
1075    /// `"target-HandoffAccept"`, not `"implicit-accept:h1"`. Renamed rather than
1076    /// left green under a false name.
1077    ///
1078    /// The §5.1(3) claim is pinned in the two places it still holds:
1079    /// - the **rev-1 arm below**, where the flag rejection is still
1080    ///   unconditional whatever the `message_id`;
1081    /// - at the **client boundary** for rev >= 2 —
1082    ///   `client_implicit_accept_rejected_at_the_boundary` case (b) submits the
1083    ///   fully well-formed synthetic shape, reserved `message_id` included, and
1084    ///   the boundary refuses it. That is the only layer that can, because the
1085    ///   mode cannot tell client provenance from runtime provenance.
1086    #[test]
1087    fn implicit_accept_with_a_non_reserved_message_id_is_rejected() {
1088        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1089        let mut session = base_session();
1090        let result = mode
1091            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1092            .unwrap();
1093        apply(&mut session, result);
1094        let result = mode
1095            .on_message(
1096                &session,
1097                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1098            )
1099            .unwrap();
1100        apply(&mut session, result);
1101        let forged_accept = HandoffAcceptPayload {
1102            handoff_id: "h1".into(),
1103            accepted_by: "target".into(),
1104            reason: "ready".into(),
1105            implicit: true,
1106        }
1107        .encode_to_vec();
1108        let err = mode
1109            .on_message(&session, &env("target", "HandoffAccept", forged_accept))
1110            .unwrap_err();
1111        assert_eq!(err.to_string(), "InvalidPayload");
1112
1113        // Rev <= 1: the flag rejection is still UNCONDITIONAL — the original
1114        // §5.1(3) claim, preserved at the revision where it holds. Even the
1115        // otherwise perfect synthetic shape (reserved `message_id`, right
1116        // sender, right `accepted_by`) is refused, because no rev <= 1 runtime
1117        // ever synthesized one and no rev <= 1 log can contain one.
1118        let mut legacy = session.clone();
1119        legacy.semantics_rev = 1;
1120        let mut perfect = env(
1121            "target",
1122            "HandoffAccept",
1123            make_implicit_accept("h1", "target"),
1124        );
1125        perfect.message_id = reserved_id("h1");
1126        assert_eq!(
1127            mode.on_message(&legacy, &perfect).unwrap_err().to_string(),
1128            "InvalidPayload"
1129        );
1130        // ... while at the current revision the identical envelope is
1131        // ACCEPTED through dispatch (see `implicit_accept_dispatch_accepted_at_rev2`).
1132        // The differential is the point: without it, deleting the rev gate
1133        // would leave this test green.
1134        assert!(mode.on_message(&session, &perfect).is_ok());
1135    }
1136
1137    #[test]
1138    fn wrong_target_cannot_accept() {
1139        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1140        let mut session = base_session();
1141        let result = mode
1142            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1143            .unwrap();
1144        apply(&mut session, result);
1145        let result = mode
1146            .on_message(
1147                &session,
1148                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1149            )
1150            .unwrap();
1151        apply(&mut session, result);
1152        let err = mode
1153            .on_message(
1154                &session,
1155                &env("owner", "HandoffAccept", make_accept("h1", "owner")),
1156            )
1157            .unwrap_err();
1158        assert_eq!(err.to_string(), "Forbidden");
1159    }
1160
1161    #[test]
1162    fn target_can_decline() {
1163        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1164        let mut session = base_session();
1165        let result = mode
1166            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1167            .unwrap();
1168        apply(&mut session, result);
1169        let result = mode
1170            .on_message(
1171                &session,
1172                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1173            )
1174            .unwrap();
1175        apply(&mut session, result);
1176        let result = mode
1177            .on_message(
1178                &session,
1179                &env("target", "HandoffDecline", make_decline("h1", "target")),
1180            )
1181            .unwrap();
1182        match result {
1183            ModeResponse::PersistState(data) => {
1184                let state: HandoffState = serde_json::from_slice(&data).unwrap();
1185                assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined);
1186            }
1187            _ => panic!("Expected PersistState"),
1188        }
1189    }
1190
1191    #[test]
1192    fn cannot_accept_already_accepted() {
1193        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1194        let mut session = base_session();
1195        let result = mode
1196            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1197            .unwrap();
1198        apply(&mut session, result);
1199        let result = mode
1200            .on_message(
1201                &session,
1202                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1203            )
1204            .unwrap();
1205        apply(&mut session, result);
1206        let result = mode
1207            .on_message(
1208                &session,
1209                &env("target", "HandoffAccept", make_accept("h1", "target")),
1210            )
1211            .unwrap();
1212        apply(&mut session, result);
1213        let err = mode
1214            .on_message(
1215                &session,
1216                &env("target", "HandoffAccept", make_accept("h1", "target")),
1217            )
1218            .unwrap_err();
1219        assert_eq!(err.to_string(), "InvalidPayload");
1220    }
1221
1222    // --- Commitment ---
1223
1224    #[test]
1225    fn commitment_after_accept() {
1226        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1227        let mut session = base_session();
1228        let result = mode
1229            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1230            .unwrap();
1231        apply(&mut session, result);
1232        let result = mode
1233            .on_message(
1234                &session,
1235                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1236            )
1237            .unwrap();
1238        apply(&mut session, result);
1239        let result = mode
1240            .on_message(
1241                &session,
1242                &env("target", "HandoffAccept", make_accept("h1", "target")),
1243            )
1244            .unwrap();
1245        apply(&mut session, result);
1246        let result = mode
1247            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1248            .unwrap();
1249        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1250    }
1251
1252    #[test]
1253    fn commitment_after_decline() {
1254        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1255        let mut session = base_session();
1256        let result = mode
1257            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1258            .unwrap();
1259        apply(&mut session, result);
1260        let result = mode
1261            .on_message(
1262                &session,
1263                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1264            )
1265            .unwrap();
1266        apply(&mut session, result);
1267        let result = mode
1268            .on_message(
1269                &session,
1270                &env("target", "HandoffDecline", make_decline("h1", "target")),
1271            )
1272            .unwrap();
1273        apply(&mut session, result);
1274        let result = mode
1275            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1276            .unwrap();
1277        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1278    }
1279
1280    #[test]
1281    fn commitment_without_response_rejected() {
1282        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1283        let mut session = base_session();
1284        let result = mode
1285            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1286            .unwrap();
1287        apply(&mut session, result);
1288        let result = mode
1289            .on_message(
1290                &session,
1291                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1292            )
1293            .unwrap();
1294        apply(&mut session, result);
1295        let err = mode
1296            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1297            .unwrap_err();
1298        assert_eq!(err.to_string(), "InvalidPayload");
1299    }
1300
1301    #[test]
1302    fn commitment_with_no_offers_rejected() {
1303        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1304        let mut session = base_session();
1305        let result = mode
1306            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1307            .unwrap();
1308        apply(&mut session, result);
1309        let err = mode
1310            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1311            .unwrap_err();
1312        assert_eq!(err.to_string(), "InvalidPayload");
1313    }
1314
1315    // --- Full lifecycle ---
1316
1317    #[test]
1318    fn full_handoff_lifecycle() {
1319        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1320        let mut session = base_session();
1321        let result = mode
1322            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1323            .unwrap();
1324        apply(&mut session, result);
1325        let result = mode
1326            .on_message(
1327                &session,
1328                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1329            )
1330            .unwrap();
1331        apply(&mut session, result);
1332        let result = mode
1333            .on_message(
1334                &session,
1335                &env("owner", "HandoffContext", make_context("h1")),
1336            )
1337            .unwrap();
1338        apply(&mut session, result);
1339        let result = mode
1340            .on_message(
1341                &session,
1342                &env("target", "HandoffAccept", make_accept("h1", "target")),
1343            )
1344            .unwrap();
1345        apply(&mut session, result);
1346        let result = mode
1347            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1348            .unwrap();
1349        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1350    }
1351
1352    // --- Serial offer enforcement ---
1353
1354    #[test]
1355    fn second_offer_while_first_pending_rejected() {
1356        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1357        let mut session = base_session();
1358        session.participants = vec!["owner".into(), "target".into(), "other".into()];
1359        let result = mode
1360            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1361            .unwrap();
1362        apply(&mut session, result);
1363        let result = mode
1364            .on_message(
1365                &session,
1366                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1367            )
1368            .unwrap();
1369        apply(&mut session, result);
1370        let err = mode
1371            .on_message(
1372                &session,
1373                &env("owner", "HandoffOffer", make_offer("h2", "other")),
1374            )
1375            .unwrap_err();
1376        assert_eq!(err.to_string(), "InvalidPayload");
1377    }
1378
1379    #[test]
1380    fn second_offer_after_first_accepted_is_rejected() {
1381        // RFC-MACP-0010: "Once an offer is accepted, no further offers may be issued."
1382        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1383        let mut session = base_session();
1384        session.participants = vec!["owner".into(), "target".into(), "other".into()];
1385        let result = mode
1386            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1387            .unwrap();
1388        apply(&mut session, result);
1389        let result = mode
1390            .on_message(
1391                &session,
1392                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1393            )
1394            .unwrap();
1395        apply(&mut session, result);
1396        let result = mode
1397            .on_message(
1398                &session,
1399                &env("target", "HandoffAccept", make_accept("h1", "target")),
1400            )
1401            .unwrap();
1402        apply(&mut session, result);
1403        let err = mode
1404            .on_message(
1405                &session,
1406                &env("owner", "HandoffOffer", make_offer("h2", "other")),
1407            )
1408            .unwrap_err();
1409        assert_eq!(err.to_string(), "InvalidPayload");
1410    }
1411
1412    #[test]
1413    fn second_offer_after_first_declined_succeeds() {
1414        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1415        let mut session = base_session();
1416        session.participants = vec!["owner".into(), "target".into(), "other".into()];
1417        let result = mode
1418            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1419            .unwrap();
1420        apply(&mut session, result);
1421        let result = mode
1422            .on_message(
1423                &session,
1424                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1425            )
1426            .unwrap();
1427        apply(&mut session, result);
1428        let result = mode
1429            .on_message(
1430                &session,
1431                &env("target", "HandoffDecline", make_decline("h1", "target")),
1432            )
1433            .unwrap();
1434        apply(&mut session, result);
1435        mode.on_message(
1436            &session,
1437            &env("owner", "HandoffOffer", make_offer("h2", "other")),
1438        )
1439        .unwrap();
1440    }
1441
1442    // --- Commitment version mismatch ---
1443
1444    #[test]
1445    fn commitment_version_mismatch_rejected() {
1446        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1447        let mut session = base_session();
1448        let result = mode
1449            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1450            .unwrap();
1451        apply(&mut session, result);
1452        let result = mode
1453            .on_message(
1454                &session,
1455                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1456            )
1457            .unwrap();
1458        apply(&mut session, result);
1459        let result = mode
1460            .on_message(
1461                &session,
1462                &env("target", "HandoffAccept", make_accept("h1", "target")),
1463            )
1464            .unwrap();
1465        apply(&mut session, result);
1466        let bad_commitment = CommitmentPayload {
1467            commitment_id: "c1".into(),
1468            action: "handoff.accepted".into(),
1469            authority_scope: "support".into(),
1470            reason: "accepted".into(),
1471            mode_version: "wrong".into(),
1472            policy_version: "policy".into(),
1473            configuration_version: "config".into(),
1474            outcome_positive: true,
1475            supersedes: None,
1476        }
1477        .encode_to_vec();
1478        let err = mode
1479            .on_message(&session, &env("owner", "Commitment", bad_commitment))
1480            .unwrap_err();
1481        assert_eq!(err.to_string(), "InvalidPayload");
1482    }
1483
1484    // --- Unknown message type ---
1485
1486    #[test]
1487    fn unknown_message_type_rejected() {
1488        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1489        let mut session = base_session();
1490        let result = mode
1491            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1492            .unwrap();
1493        apply(&mut session, result);
1494        let err = mode
1495            .on_message(&session, &env("owner", "CustomType", vec![]))
1496            .unwrap_err();
1497        assert_eq!(err.to_string(), "InvalidPayload");
1498    }
1499
1500    #[test]
1501    fn context_after_accept_is_permitted() {
1502        // RFC-MACP-0010 §2.1: Late context after accept/decline is permitted
1503        // as supplementary documentation.
1504        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1505        let mut session = base_session();
1506        let resp = mode
1507            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1508            .unwrap();
1509        apply(&mut session, resp);
1510        let resp = mode
1511            .on_message(
1512                &session,
1513                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1514            )
1515            .unwrap();
1516        apply(&mut session, resp);
1517        let resp = mode
1518            .on_message(
1519                &session,
1520                &env("target", "HandoffAccept", make_accept("h1", "target")),
1521            )
1522            .unwrap();
1523        apply(&mut session, resp);
1524        // Late context after accept should succeed
1525        let result = mode.on_message(
1526            &session,
1527            &env("owner", "HandoffContext", make_context("h1")),
1528        );
1529        assert!(
1530            result.is_ok(),
1531            "late HandoffContext should be permitted per RFC"
1532        );
1533    }
1534
1535    // --- Policy ---
1536
1537    #[test]
1538    fn handoff_policy_evaluator_always_allows() {
1539        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1540        let mut session = base_session();
1541        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1542            policy_id: "test-handoff".into(),
1543            mode: "macp.mode.handoff.v1".into(),
1544            description: "handoff policy".into(),
1545            rules: serde_json::json!({
1546                "acceptance": { "implicit_accept_timeout_ms": 0 },
1547                "commitment": { "authority": "initiator_only" }
1548            }),
1549            schema_version: 1,
1550        });
1551        let result = mode
1552            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1553            .unwrap();
1554        apply(&mut session, result);
1555        let result = mode
1556            .on_message(
1557                &session,
1558                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1559            )
1560            .unwrap();
1561        apply(&mut session, result);
1562        let result = mode
1563            .on_message(
1564                &session,
1565                &env("target", "HandoffAccept", make_accept("h1", "target")),
1566            )
1567            .unwrap();
1568        apply(&mut session, result);
1569        // Handoff policy evaluator always allows — commitment should succeed
1570        let result = mode
1571            .on_message(&session, &env("owner", "Commitment", commitment_payload()))
1572            .unwrap();
1573        assert!(matches!(result, ModeResponse::PersistAndResolve { .. }));
1574    }
1575
1576    // --- Second HandoffOffer while first pending ---
1577
1578    #[test]
1579    fn second_offer_to_different_target_while_first_pending_rejected() {
1580        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1581        let mut session = base_session();
1582        session.participants = vec!["owner".into(), "targetA".into(), "targetB".into()];
1583        let result = mode
1584            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1585            .unwrap();
1586        apply(&mut session, result);
1587        // First offer to targetA — succeeds
1588        let result = mode
1589            .on_message(
1590                &session,
1591                &env("owner", "HandoffOffer", make_offer("h1", "targetA")),
1592            )
1593            .unwrap();
1594        apply(&mut session, result);
1595        // Second offer to targetB while h1 is still pending — rejected
1596        let err = mode
1597            .on_message(
1598                &session,
1599                &env("owner", "HandoffOffer", make_offer("h2", "targetB")),
1600            )
1601            .unwrap_err();
1602        assert_eq!(err.to_string(), "InvalidPayload");
1603    }
1604
1605    // --- After HandoffAccept, further offers are allowed (prior resolved) ---
1606
1607    #[test]
1608    fn offer_after_accept_blocked_per_rfc() {
1609        // RFC-MACP-0010: "Once an offer is accepted, no further offers may be issued
1610        // for the Session. Only one final Commitment may resolve the Session."
1611        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1612        let mut session = base_session();
1613        session.participants = vec!["owner".into(), "target".into(), "other".into()];
1614        let result = mode
1615            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1616            .unwrap();
1617        apply(&mut session, result);
1618        let result = mode
1619            .on_message(
1620                &session,
1621                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1622            )
1623            .unwrap();
1624        apply(&mut session, result);
1625        let result = mode
1626            .on_message(
1627                &session,
1628                &env("target", "HandoffAccept", make_accept("h1", "target")),
1629            )
1630            .unwrap();
1631        apply(&mut session, result);
1632        // New HandoffOffer MUST be rejected after an offer has been accepted
1633        let err = mode
1634            .on_message(
1635                &session,
1636                &env("owner", "HandoffOffer", make_offer("h2", "other")),
1637            )
1638            .unwrap_err();
1639        assert_eq!(err.to_string(), "InvalidPayload");
1640        let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1641        assert_eq!(state.offers.len(), 1);
1642        assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted);
1643    }
1644
1645    #[test]
1646    fn offered_at_ms_is_populated() {
1647        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1648        let mut session = base_session();
1649        session.participants = vec!["owner".into(), "target".into()];
1650        let result = mode
1651            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1652            .unwrap();
1653        apply(&mut session, result);
1654        let result = mode
1655            .on_message(
1656                &session,
1657                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1658            )
1659            .unwrap();
1660        apply(&mut session, result);
1661        let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1662        assert!(
1663            state.offers["h1"].offered_at_ms > 0,
1664            "offered_at_ms should be set"
1665        );
1666    }
1667
1668    /// The **interim** in-`Commitment` implicit accept, pinned at the last
1669    /// revision that has it.
1670    ///
1671    /// "Timeout set + enough time elapsed ⇒ auto-accepted at commitment" is
1672    /// the interim path's claim, and it stays exactly true at rev <= 1. At
1673    /// rev >= 2 the accept is a recorded event synthesized by the kernel
1674    /// *before* the commitment is dispatched
1675    /// (`Runtime::synthesize_due_accept`), and the interim mutation is gated
1676    /// off — so the rev-2 successor of this test is the live-runtime
1677    /// `lazy_synthesis_enters_history_before_the_trigger`, plus
1678    /// `implicit_accept_outcome_via_hook` at this level.
1679    #[test]
1680    fn implicit_accept_timeout_fires() {
1681        // RFC-MACP-0010: when implicit_accept_timeout_ms policy is set and
1682        // sufficient time has elapsed, the offer is auto-accepted at commitment.
1683        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1684        let mut session = base_session();
1685        session.semantics_rev = 1;
1686        session.participants = vec!["owner".into(), "target".into()];
1687        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
1688            policy_id: "auto-accept".into(),
1689            mode: "macp.mode.handoff.v1".into(),
1690            description: "short timeout".into(),
1691            rules: serde_json::json!({
1692                "acceptance": { "implicit_accept_timeout_ms": 100 },
1693                "commitment": { "authority": "initiator_only" }
1694            }),
1695            schema_version: 1,
1696        });
1697        let result = mode
1698            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1699            .unwrap();
1700        apply(&mut session, result);
1701        // Offer with a specific timestamp
1702        let offer_time = 1000i64;
1703        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1704        offer_env.timestamp_unix_ms = offer_time;
1705        let result = mode.on_message(&session, &offer_env).unwrap();
1706        apply(&mut session, result);
1707        // Commitment with timestamp past the timeout (offer_time + 100ms = 1100)
1708        let mut commit_env = env("owner", "Commitment", commitment_payload());
1709        commit_env.timestamp_unix_ms = offer_time + 200; // well past 100ms timeout
1710        let commit = mode.on_message(&session, &commit_env).unwrap();
1711        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1712    }
1713
1714    fn auto_accept_policy() -> macp_core::policy::PolicyDefinition {
1715        macp_core::policy::PolicyDefinition {
1716            policy_id: "auto-accept".into(),
1717            mode: "macp.mode.handoff.v1".into(),
1718            description: "short timeout".into(),
1719            rules: serde_json::json!({
1720                "acceptance": { "implicit_accept_timeout_ms": 100 },
1721                "commitment": { "authority": "initiator_only" }
1722            }),
1723            schema_version: 1,
1724        }
1725    }
1726
1727    /// Semantics rev 1: the implicit-accept timeout is measured against the
1728    /// runtime acceptance clock, so an initiator post-dating the Commitment
1729    /// envelope can no longer finalize an offer the target never accepted.
1730    ///
1731    /// Pinned to rev 1 because the interim in-`Commitment` path this drives is
1732    /// gated off at rev >= 2. The same forgery is closed at rev 2 by a
1733    /// different mechanism — the kernel, not the envelope, supplies `now_ms`
1734    /// to `due_synthetic_envelope` — which the rev-2 arm at the end asserts so
1735    /// the claim is not silently dropped by the pin.
1736    #[test]
1737    fn implicit_accept_ignores_forged_envelope_timestamp_on_rev1() {
1738        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1739        let mut session = base_session();
1740        session.semantics_rev = 1;
1741        session.participants = vec!["owner".into(), "target".into()];
1742        session.policy_definition = Some(auto_accept_policy());
1743        let result = mode
1744            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1745            .unwrap();
1746        apply(&mut session, result);
1747
1748        let offer_time = 1000i64;
1749        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1750        offer_env.timestamp_unix_ms = offer_time;
1751        let result = mode.on_message(&session, &offer_env).unwrap();
1752        apply(&mut session, result);
1753
1754        // Initiator forges a far-future envelope timestamp, but the runtime's
1755        // acceptance clock says only 50ms elapsed: no implicit accept, and the
1756        // commitment is not ready (no accepted offer) -> rejected.
1757        let mut commit_env = env("owner", "Commitment", commitment_payload());
1758        commit_env.timestamp_unix_ms = offer_time + 1_000_000;
1759        let ctx = macp_core::mode::MessageContext::new(offer_time + 50);
1760        let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1761        assert_eq!(err.to_string(), "InvalidPayload");
1762
1763        // With genuine elapsed acceptance time past the timeout, it fires.
1764        let ctx = macp_core::mode::MessageContext::new(offer_time + 200);
1765        let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1766        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1767
1768        // Rev 2: the same forgery, closed by the synthesis seam instead. The
1769        // envelope clock is nowhere in `due_synthetic_envelope`'s inputs, so
1770        // the far-future `commit_env.timestamp_unix_ms` above cannot make the
1771        // accept due; only the kernel-supplied `now_ms` can.
1772        let mut rev2 = session.clone();
1773        rev2.semantics_rev = 2;
1774        assert!(
1775            mode.due_synthetic_envelope(&rev2, offer_time + 50)
1776                .is_none(),
1777            "50ms of kernel time must not make the accept due, whatever the envelope claims"
1778        );
1779        assert!(mode
1780            .due_synthetic_envelope(&rev2, offer_time + 200)
1781            .is_some());
1782    }
1783
1784    /// Legacy sessions (rev 0) keep the envelope-timestamp clock through the
1785    /// kernel entry point, so pre-fix histories replay to the outcome they
1786    /// were accepted with.
1787    #[test]
1788    fn implicit_accept_legacy_rev0_keeps_envelope_clock() {
1789        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1790        let mut session = base_session();
1791        session.semantics_rev = 0;
1792        session.participants = vec!["owner".into(), "target".into()];
1793        session.policy_definition = Some(auto_accept_policy());
1794        let result = mode
1795            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1796            .unwrap();
1797        apply(&mut session, result);
1798
1799        let offer_time = 1000i64;
1800        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1801        offer_env.timestamp_unix_ms = offer_time;
1802        let result = mode.on_message(&session, &offer_env).unwrap();
1803        apply(&mut session, result);
1804
1805        // Legacy semantics: the envelope timestamp drives the timeout even
1806        // when the acceptance clock disagrees (as it did before the fix).
1807        let mut commit_env = env("owner", "Commitment", commitment_payload());
1808        commit_env.timestamp_unix_ms = offer_time + 200;
1809        let ctx = macp_core::mode::MessageContext::new(offer_time + 10);
1810        let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1811        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1812    }
1813
1814    /// The offer-side twin of the forged-commitment test: on rev >= 1 the
1815    /// offer time is the runtime acceptance clock, so BACK-dating the
1816    /// HandoffOffer envelope no longer forges elapsed time past the
1817    /// implicit-accept timeout.
1818    ///
1819    /// Pinned to rev 1 for the same reason as its commitment-side twin: the
1820    /// interim path is gated off at rev >= 2. The rev-2 arm at the end keeps
1821    /// the claim covered through the synthesis seam.
1822    #[test]
1823    fn implicit_accept_ignores_backdated_offer_timestamp_on_rev1() {
1824        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1825        let mut session = base_session();
1826        session.semantics_rev = 1;
1827        session.participants = vec!["owner".into(), "target".into()];
1828        session.policy_definition = Some(auto_accept_policy());
1829        let result = mode
1830            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1831            .unwrap();
1832        apply(&mut session, result);
1833
1834        // Offer envelope BACK-dated far into the past, but accepted "now".
1835        let now = 1_000_000i64;
1836        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1837        offer_env.timestamp_unix_ms = now - 1_000_000; // forged past
1838        let ctx = macp_core::mode::MessageContext::new(now);
1839        let result = mode.on_message_at(&session, &offer_env, &ctx).unwrap();
1840        apply(&mut session, result);
1841
1842        // Commitment accepted 50ms later: elapsed (per the acceptance clock)
1843        // is 50ms < 100ms timeout — no implicit accept, commitment rejected.
1844        let mut commit_env = env("owner", "Commitment", commitment_payload());
1845        commit_env.timestamp_unix_ms = now + 50;
1846        let ctx = macp_core::mode::MessageContext::new(now + 50);
1847        let err = mode.on_message_at(&session, &commit_env, &ctx).unwrap_err();
1848        assert_eq!(err.to_string(), "InvalidPayload");
1849
1850        // With genuinely elapsed acceptance time, it fires.
1851        let ctx = macp_core::mode::MessageContext::new(now + 200);
1852        let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1853        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
1854
1855        // Rev 2: the recorded `offered_at_ms` is still the acceptance clock,
1856        // so the back-dated offer envelope cannot make the synthetic accept
1857        // due early either.
1858        let mut rev2 = session.clone();
1859        rev2.semantics_rev = 2;
1860        assert!(mode.due_synthetic_envelope(&rev2, now + 50).is_none());
1861        assert_eq!(
1862            mode.due_synthetic_envelope(&rev2, now + 200)
1863                .expect("due once 100ms of kernel time has elapsed")
1864                .timestamp_unix_ms,
1865            now + 100,
1866            "the deadline is measured from the acceptance clock, not the forged envelope"
1867        );
1868    }
1869
1870    /// The offer snapshots the session's cumulative suspension so the rev >= 2
1871    /// deadline can later subtract only the suspension accrued *after* the
1872    /// offer. Snapshot, not a live read.
1873    #[test]
1874    fn offer_records_suspension_snapshot() {
1875        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1876        let mut session = base_session();
1877        // The session was already suspended once before the offer landed.
1878        session.accumulated_suspended_ms = 5_000;
1879        let result = mode
1880            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1881            .unwrap();
1882        apply(&mut session, result);
1883
1884        let ctx = macp_core::mode::MessageContext::new(1_000);
1885        let result = mode
1886            .on_message_at(
1887                &session,
1888                &env("owner", "HandoffOffer", make_offer("h1", "target")),
1889                &ctx,
1890            )
1891            .unwrap();
1892        apply(&mut session, result);
1893
1894        let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1895        assert_eq!(state.offers["h1"].suspended_ms_at_offer, 5_000);
1896    }
1897
1898    /// Legacy-state fixture: `mode_state` written before the snapshot field
1899    /// existed must still decode (serde default) and must still reach the
1900    /// outcome it was accepted with — the implicit-accept timeout fires off
1901    /// the recorded `offered_at_ms` exactly as before.
1902    ///
1903    /// Driven at `semantics_rev = 1`, which is what such a `mode_state`
1904    /// actually belongs to: the field was added by rev 2, so no rev-2 session
1905    /// can have written this shape. The commitment half therefore exercises
1906    /// the interim path, which is exactly the path that wrote it.
1907    #[test]
1908    fn legacy_offer_mode_state_without_suspension_snapshot_replays_unchanged() {
1909        let legacy_state = serde_json::json!({
1910            "offers": {
1911                "h1": {
1912                    "handoff_id": "h1",
1913                    "target_participant": "target",
1914                    "scope": "support",
1915                    "reason": "escalate",
1916                    "offered_by": "owner",
1917                    "disposition": "Offered",
1918                    "accepted_by": null,
1919                    "declined_by": null,
1920                    "outcome_reason": null,
1921                    "offered_at_ms": 1000
1922                }
1923            },
1924            "contexts": {}
1925        });
1926        let decoded: HandoffState = serde_json::from_value(legacy_state.clone()).unwrap();
1927        assert_eq!(decoded.offers["h1"].suspended_ms_at_offer, 0);
1928        assert_eq!(decoded.offers["h1"].offered_at_ms, 1000);
1929
1930        // And it still drives the original outcome: 200ms of elapsed
1931        // acceptance time past a 100ms timeout implicitly accepts, so the
1932        // commitment resolves.
1933        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1934        let mut session = base_session();
1935        session.semantics_rev = 1;
1936        session.policy_definition = Some(auto_accept_policy());
1937        session.mode_state = serde_json::to_vec(&legacy_state).unwrap();
1938        let commit_env = env("owner", "Commitment", commitment_payload());
1939        let ctx = macp_core::mode::MessageContext::new(1_200);
1940        let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
1941        apply(&mut session, commit);
1942        let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap();
1943        assert_eq!(
1944            state.offers["h1"].outcome_reason.as_deref(),
1945            Some("implicit accept (timeout)")
1946        );
1947    }
1948
1949    /// The offer time both revision tests anchor on.
1950    const OFFER_TIME_MS: i64 = 1_000;
1951
1952    /// Drive one `offer -> [suspend/resume] -> Commitment` sequence under a
1953    /// chosen semantics revision and report whether the commitment resolved
1954    /// the session. Under [`auto_accept_policy`] the target never accepts
1955    /// explicitly, so `Ok(true)` can only mean the offer was implicitly
1956    /// accepted; `Err("InvalidPayload")` is the no-accepted-offer rejection.
1957    ///
1958    /// Both envelope timestamps are pinned to the acceptance clocks so the two
1959    /// clocks agree — that isolates the suspension term as the only thing the
1960    /// revision can change, and lets rev 0 (envelope clock) be driven here
1961    /// too.
1962    ///
1963    /// The pause runs through the real `Session::suspend`/`resume` pair, so
1964    /// `accumulated_suspended_ms` is banked exactly the way the kernel
1965    /// (`RuntimeCore::resume_session`) and replay (`replay_entry`'s
1966    /// `SessionResume` arm) bank it.
1967    fn implicit_accept_outcome(
1968        rev: u32,
1969        suspended_after_offer_ms: i64,
1970        commit_at: i64,
1971    ) -> Result<bool, String> {
1972        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
1973        let mut session = base_session();
1974        session.semantics_rev = rev;
1975        session.policy_definition = Some(auto_accept_policy());
1976        let result = mode
1977            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
1978            .unwrap();
1979        apply(&mut session, result);
1980
1981        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
1982        offer_env.timestamp_unix_ms = OFFER_TIME_MS;
1983        let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
1984        let result = mode
1985            .on_message_at(&session, &offer_env, &offer_ctx)
1986            .unwrap();
1987        apply(&mut session, result);
1988
1989        // The pause happens between the offer and the commitment. Messages are
1990        // refused while suspended (`crate::step::check_preconditions`), so by
1991        // the time the commitment is processed the pause is always banked and
1992        // `suspended_at_ms` is back to `None`.
1993        if suspended_after_offer_ms > 0 {
1994            session.suspend(OFFER_TIME_MS).unwrap();
1995            session
1996                .resume(OFFER_TIME_MS + suspended_after_offer_ms)
1997                .unwrap();
1998            assert_eq!(session.accumulated_suspended_ms, suspended_after_offer_ms);
1999            assert_eq!(session.suspended_at_ms, None);
2000        }
2001
2002        let mut commit_env = env("owner", "Commitment", commitment_payload());
2003        commit_env.timestamp_unix_ms = commit_at;
2004        let ctx = macp_core::mode::MessageContext::new(commit_at);
2005        mode.on_message_at(&session, &commit_env, &ctx)
2006            .map(|r| matches!(r, ModeResponse::PersistAndResolve { .. }))
2007            .map_err(|e| e.to_string())
2008    }
2009
2010    /// The rev-2 sibling of [`implicit_accept_outcome`]: the same sequence,
2011    /// but with the kernel's synthesis step in front of the commitment.
2012    ///
2013    /// It is a faithful in-mode model of `Runtime::synthesize_due_accept` —
2014    /// ask `due_synthetic_envelope` with the trigger's clock, and if something
2015    /// is due, authorize it and dispatch it through `on_message_at` with
2016    /// `accepted_at_ms` equal to the envelope's own `timestamp_unix_ms` —
2017    /// minus the durable append, which has no meaning at this level. The live
2018    /// kernel wiring is proved end-to-end in
2019    /// `tests/handoff_implicit_accept_live.rs`.
2020    ///
2021    /// Returning the *same* `Result<bool, String>` shape as
2022    /// [`implicit_accept_outcome`] is deliberate: the rev-2 rows of a table
2023    /// can then be compared against the rev-1 rows directly.
2024    fn implicit_accept_outcome_via_hook(
2025        suspended_after_offer_ms: i64,
2026        commit_at: i64,
2027    ) -> Result<bool, String> {
2028        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2029        let mut session = base_session();
2030        session.semantics_rev = 2;
2031        session.policy_definition = Some(auto_accept_policy());
2032        let result = mode
2033            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2034            .unwrap();
2035        apply(&mut session, result);
2036
2037        let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target"));
2038        offer_env.timestamp_unix_ms = OFFER_TIME_MS;
2039        let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2040        let result = mode
2041            .on_message_at(&session, &offer_env, &offer_ctx)
2042            .unwrap();
2043        apply(&mut session, result);
2044
2045        if suspended_after_offer_ms > 0 {
2046            session.suspend(OFFER_TIME_MS).unwrap();
2047            session
2048                .resume(OFFER_TIME_MS + suspended_after_offer_ms)
2049                .unwrap();
2050        }
2051
2052        // The synthesis seam, in the kernel's order: before the trigger.
2053        if let Some(syn) = mode.due_synthetic_envelope(&session, commit_at) {
2054            mode.authorize_sender(&session, &syn)
2055                .map_err(|e| e.to_string())?;
2056            let syn_ctx = macp_core::mode::MessageContext::new(syn.timestamp_unix_ms);
2057            let response = mode
2058                .on_message_at(&session, &syn, &syn_ctx)
2059                .map_err(|e| e.to_string())?;
2060            apply(&mut session, response);
2061        }
2062
2063        let mut commit_env = env("owner", "Commitment", commitment_payload());
2064        commit_env.timestamp_unix_ms = commit_at;
2065        let ctx = macp_core::mode::MessageContext::new(commit_at);
2066        mode.on_message_at(&session, &commit_env, &ctx)
2067            .map(|r| matches!(r, ModeResponse::PersistAndResolve { .. }))
2068            .map_err(|e| e.to_string())
2069    }
2070
2071    /// RFC-MACP-0010 §5.1(1): time the session spends `Suspended` must not
2072    /// count toward `implicit_accept_timeout_ms`. Rev 2 honors that; revs 0
2073    /// and 1 kept counting it and MUST keep counting it, or histories they
2074    /// already resolved stop replaying.
2075    ///
2076    /// The offer is outstanding for 300ms, 250ms of it suspended: 50ms of live
2077    /// time against the policy's 100ms timeout.
2078    #[test]
2079    fn rev2_stops_counting_suspended_time_toward_implicit_accept() {
2080        let commit_at = OFFER_TIME_MS + 300;
2081        assert_eq!(
2082            implicit_accept_outcome(2, 250, commit_at),
2083            Err("InvalidPayload".into()),
2084            "rev 2: 50ms of unsuspended time must not implicitly accept"
2085        );
2086        assert_eq!(
2087            implicit_accept_outcome(1, 250, commit_at),
2088            Ok(true),
2089            "rev 1 must keep the legacy arithmetic exactly (suspended time counts)"
2090        );
2091        assert_eq!(
2092            implicit_accept_outcome(0, 250, commit_at),
2093            Ok(true),
2094            "rev 0 must keep the legacy arithmetic exactly (suspended time counts)"
2095        );
2096    }
2097
2098    /// The correction changes *only* the suspended case: with no suspension
2099    /// every revision agrees, and a suspension that still leaves the timeout
2100    /// cleared on live time alone accepts under rev 2 as well — including
2101    /// exactly at the boundary, since the comparison stays `>=`.
2102    ///
2103    /// The rev-2 rows run through [`implicit_accept_outcome_via_hook`], not
2104    /// the plain helper: from rev 2 the accept is only ever reached by the
2105    /// kernel's synthesis step, so the table is a claim about the hook's
2106    /// arithmetic. The plain helper is kept alongside as the cutover
2107    /// assertion — at rev 2, *without* the synthetic entry, the commitment
2108    /// never resolves, whatever the arithmetic says.
2109    #[test]
2110    fn rev2_matches_legacy_arithmetic_when_nothing_was_suspended() {
2111        for (suspended, commit_at, expected) in [
2112            // No suspension at all: identical to rev 1 either side of the timeout.
2113            (0i64, OFFER_TIME_MS + 200, Ok(true)),
2114            (0, OFFER_TIME_MS + 50, Err("InvalidPayload".to_string())),
2115            // 200ms suspended out of 300ms: exactly 100ms live == the timeout.
2116            (200, OFFER_TIME_MS + 300, Ok(true)),
2117            // One millisecond short of the boundary.
2118            (201, OFFER_TIME_MS + 300, Err("InvalidPayload".to_string())),
2119        ] {
2120            assert_eq!(
2121                implicit_accept_outcome_via_hook(suspended, commit_at),
2122                expected,
2123                "rev 2 via the synthesis hook (suspended={suspended}, commit_at={commit_at})"
2124            );
2125            // The cutover: the interim in-`Commitment` mutation is gone at
2126            // rev 2, so the identical sequence WITHOUT the hook never resolves
2127            // — including the rows the hook accepts.
2128            assert_eq!(
2129                implicit_accept_outcome(2, suspended, commit_at),
2130                Err("InvalidPayload".to_string()),
2131                "rev 2 must not implicitly accept without the synthetic entry \
2132                 (suspended={suspended}, commit_at={commit_at})"
2133            );
2134            if suspended == 0 {
2135                assert_eq!(
2136                    implicit_accept_outcome(1, suspended, commit_at),
2137                    expected,
2138                    "rev 1 must agree when nothing was suspended"
2139                );
2140            }
2141        }
2142    }
2143
2144    /// Only suspension accrued *after* the offer is excluded. A session that
2145    /// was paused before the offer was ever made has that pause in
2146    /// `accumulated_suspended_ms`, and subtracting it would push the deadline
2147    /// out for a window the offer did not exist in — which is why the offer
2148    /// snapshots the counter.
2149    #[test]
2150    fn rev2_subtracts_only_suspension_accrued_after_the_offer() {
2151        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2152        let mut session = base_session();
2153        session.policy_definition = Some(auto_accept_policy());
2154        // A 5s pause that ended before the offer was made.
2155        session.accumulated_suspended_ms = 5_000;
2156        let result = mode
2157            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2158            .unwrap();
2159        apply(&mut session, result);
2160
2161        let offer_ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2162        let result = mode
2163            .on_message_at(
2164                &session,
2165                &env("owner", "HandoffOffer", make_offer("h1", "target")),
2166                &offer_ctx,
2167            )
2168            .unwrap();
2169        apply(&mut session, result);
2170
2171        // 200ms of live time, nothing suspended since the offer: the accept is
2172        // due. Driven through the synthesis seam because that is the only
2173        // place the rev-2 accept happens now — pinning this to rev 1 instead
2174        // would make it pass for the wrong reason (rev 1 subtracts nothing at
2175        // all, so `suspended_ms_at_offer` — the field this test exists to
2176        // justify — would stop being exercised).
2177        let syn = mode
2178            .due_synthetic_envelope(&session, OFFER_TIME_MS + 200)
2179            .expect("the 5s pause ended before the offer and must not push the deadline out");
2180        assert_eq!(
2181            syn.timestamp_unix_ms,
2182            OFFER_TIME_MS + 100,
2183            "the pre-offer pause must not move the deadline"
2184        );
2185        let syn_ctx = macp_core::mode::MessageContext::new(syn.timestamp_unix_ms);
2186        let response = mode.on_message_at(&session, &syn, &syn_ctx).unwrap();
2187        apply(&mut session, response);
2188
2189        // ...and with it in history the commitment resolves.
2190        let commit_env = env("owner", "Commitment", commitment_payload());
2191        let ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS + 200);
2192        let commit = mode.on_message_at(&session, &commit_env, &ctx).unwrap();
2193        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2194    }
2195
2196    /// Unit-level guards on the rev-2 arithmetic itself. Neither input is
2197    /// reachable from runtime-written state (`accumulated_suspended_ms` only
2198    /// ever grows, and the snapshot is taken from that same counter), so these
2199    /// pin the behavior for corrupted or hand-edited persisted state: degrade
2200    /// to the legacy difference, never inflate elapsed time, never panic.
2201    #[test]
2202    fn rev2_elapsed_ms_is_saturating_and_floors_the_suspension_term() {
2203        let mut session = base_session();
2204        let mut offer = HandoffOfferRecord {
2205            handoff_id: "h1".into(),
2206            target_participant: "target".into(),
2207            scope: "support".into(),
2208            reason: "escalate".into(),
2209            offered_by: "owner".into(),
2210            disposition: HandoffDisposition::Offered,
2211            accepted_by: None,
2212            declined_by: None,
2213            outcome_reason: None,
2214            offered_at_ms: 1_000,
2215            suspended_ms_at_offer: 0,
2216        };
2217
2218        // Baseline: no suspension since the offer -> the raw difference.
2219        assert_eq!(HandoffMode::rev2_elapsed_ms(&session, &offer, 1_300), 300);
2220
2221        // A negative suspension term must NOT be added back (that would
2222        // implicitly accept an offer the target never accepted); it floors to
2223        // the legacy difference.
2224        offer.suspended_ms_at_offer = 5_000;
2225        session.accumulated_suspended_ms = 1_000;
2226        assert_eq!(HandoffMode::rev2_elapsed_ms(&session, &offer, 1_300), 300);
2227
2228        // Overflow in either subtraction saturates instead of panicking.
2229        offer.suspended_ms_at_offer = 0;
2230        session.accumulated_suspended_ms = 0;
2231        offer.offered_at_ms = i64::MIN;
2232        assert_eq!(
2233            HandoffMode::rev2_elapsed_ms(&session, &offer, i64::MAX),
2234            i64::MAX
2235        );
2236        offer.offered_at_ms = 0;
2237        session.accumulated_suspended_ms = i64::MAX;
2238        assert_eq!(
2239            HandoffMode::rev2_elapsed_ms(&session, &offer, i64::MIN),
2240            i64::MIN
2241        );
2242    }
2243
2244    /// The rev >= 2 branch is wired to the live constant, not to a hard-coded
2245    /// 2: a session built today is on the current revision and takes that
2246    /// branch.
2247    #[test]
2248    fn builder_default_session_is_on_current_semantics_rev() {
2249        assert_eq!(
2250            base_session().semantics_rev,
2251            macp_core::session::CURRENT_SEMANTICS_REV
2252        );
2253        // Compile-time: the rev >= 2 branch is reachable for new sessions at
2254        // all only while the constant stays there.
2255        const _: () = assert!(macp_core::session::CURRENT_SEMANTICS_REV >= 2);
2256    }
2257
2258    // --- The client boundary: `validate_client_envelope` (Phase 11c) ---
2259    //
2260    // RFC-MACP-0010 §5.1(3). The runtime-level halves of these criteria live
2261    // in `src/runtime.rs`
2262    // (`reserved_message_id_namespace_is_rejected_at_rev2`,
2263    // `reserved_message_id_is_rejected_on_the_session_start_path`,
2264    // `client_implicit_accept_rejected_through_the_runtime`), and the proof
2265    // that the hook is NOT on the replay path lives in `src/replay.rs`
2266    // (`reserved_prefix_entry_replays_at_every_rev`).
2267
2268    fn make_implicit_accept(handoff_id: &str, accepted_by: &str) -> Vec<u8> {
2269        HandoffAcceptPayload {
2270            handoff_id: handoff_id.into(),
2271            accepted_by: accepted_by.into(),
2272            reason: "implicit accept (timeout)".into(),
2273            implicit: true,
2274        }
2275        .encode_to_vec()
2276    }
2277
2278    fn reserved_id(handoff_id: &str) -> String {
2279        format!("{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{handoff_id}")
2280    }
2281
2282    /// A client-submitted `HandoffAccept` carrying `implicit = true` is
2283    /// rejected at the boundary (RFC-MACP-0010 §5.1(3) MUST).
2284    ///
2285    /// **Two envelopes, deliberately**, so neither rule can pass for the
2286    /// other's reason:
2287    /// (a) `implicit = true` with the natural (non-reserved) `message_id` —
2288    ///     isolates the `implicit` rule, `InvalidPayload` (the same code
2289    ///     `handle_message` returns for the same envelope today, so the rev-2
2290    ///     error surface does not shift);
2291    /// (b) `implicit = true` with the **reserved** `message_id` and correct
2292    ///     sender/`accepted_by` — byte-for-byte the envelope the runtime will
2293    ///     synthesize from 11e, and therefore the only place the flag's
2294    ///     *client provenance* can be pinned once 11d requires dispatch to
2295    ///     accept exactly that shape. It reports `InvalidEnvelope`, not
2296    ///     `InvalidPayload`, because the reserved-namespace rule is checked
2297    ///     first — asserted rather than glossed, since the ordering is what
2298    ///     makes this assertion mutation-sensitive after 11d lands.
2299    #[test]
2300    fn client_implicit_accept_rejected_at_the_boundary() {
2301        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2302        let session = base_session();
2303
2304        // (a) the `implicit` rule, isolated.
2305        let mut a = env(
2306            "target",
2307            "HandoffAccept",
2308            make_implicit_accept("h1", "target"),
2309        );
2310        assert!(
2311            !a.message_id.starts_with(IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX),
2312            "case (a) must not also trip the reserved-namespace rule"
2313        );
2314        assert!(matches!(
2315            mode.validate_client_envelope(&session, &a).unwrap_err(),
2316            MacpError::InvalidPayload
2317        ));
2318
2319        // Same envelope with `implicit = false` passes the boundary: the rule
2320        // discriminates on the flag, not on the message type.
2321        a.payload = make_accept("h1", "target");
2322        assert!(mode.validate_client_envelope(&session, &a).is_ok());
2323
2324        // (b) the runtime's own synthetic shape, submitted by a client.
2325        let mut b = env(
2326            "target",
2327            "HandoffAccept",
2328            make_implicit_accept("h1", "target"),
2329        );
2330        b.message_id = reserved_id("h1");
2331        assert!(matches!(
2332            mode.validate_client_envelope(&session, &b).unwrap_err(),
2333            MacpError::InvalidEnvelope
2334        ));
2335    }
2336
2337    /// The reserved namespace is reserved for **every** message type, not just
2338    /// `HandoffAccept`: the squat works through `SessionStart` (initiator),
2339    /// `Commitment` (commitment authority) and `HandoffContext` (the offerer)
2340    /// too, and consuming that dedup slot would make the runtime's own later
2341    /// synthesis silently skipped.
2342    ///
2343    /// Also pins that it is a **prefix** reservation, not one exact id: the
2344    /// squattable id is the one a *future* offer would use, which the boundary
2345    /// cannot enumerate.
2346    #[test]
2347    fn reserved_prefix_is_rejected_for_every_message_type() {
2348        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2349        let session = base_session();
2350
2351        for message_type in [
2352            "SessionStart",
2353            "Commitment",
2354            "HandoffOffer",
2355            "HandoffContext",
2356            "HandoffAccept",
2357            "HandoffDecline",
2358            "SomeUnknownType",
2359        ] {
2360            for suffix in ["h1", "", "a-handoff-id-that-does-not-exist-yet"] {
2361                let mut e = env("owner", message_type, vec![]);
2362                e.message_id = format!("{IMPLICIT_ACCEPT_MESSAGE_ID_PREFIX}{suffix}");
2363                assert!(
2364                    matches!(
2365                        mode.validate_client_envelope(&session, &e).unwrap_err(),
2366                        MacpError::InvalidEnvelope
2367                    ),
2368                    "{message_type} with id {} must be rejected",
2369                    e.message_id
2370                );
2371            }
2372
2373            // A near-miss id is untouched: the reservation is the prefix, and
2374            // nothing wider.
2375            let mut ok = env("owner", message_type, vec![]);
2376            ok.message_id = "implicit-accept".into(); // no trailing colon
2377            assert!(
2378                mode.validate_client_envelope(&session, &ok).is_ok(),
2379                "{message_type} with a near-miss id must pass"
2380            );
2381        }
2382    }
2383
2384    /// Rev <= 1 is untouched, so legacy histories replay bit-identically: the
2385    /// hook returns `Ok` for both rules on a legacy session, while the
2386    /// identical envelopes are rejected at the current revision.
2387    ///
2388    /// The differential half is the point — without it, deleting the rev gate
2389    /// would leave this test green.
2390    #[test]
2391    fn reserved_namespace_is_rev_gated() {
2392        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2393
2394        let mut reserved = env("owner", "HandoffContext", make_context("h1"));
2395        reserved.message_id = reserved_id("h1");
2396        let implicit = env(
2397            "target",
2398            "HandoffAccept",
2399            make_implicit_accept("h1", "target"),
2400        );
2401
2402        for rev in [0, 1] {
2403            let mut legacy = base_session();
2404            legacy.semantics_rev = rev;
2405            assert!(
2406                mode.validate_client_envelope(&legacy, &reserved).is_ok(),
2407                "rev {rev} must accept a reserved-prefix id"
2408            );
2409            assert!(
2410                mode.validate_client_envelope(&legacy, &implicit).is_ok(),
2411                "rev {rev} must leave the implicit flag to dispatch"
2412            );
2413        }
2414
2415        let current = base_session();
2416        assert_eq!(
2417            current.semantics_rev,
2418            macp_core::session::CURRENT_SEMANTICS_REV
2419        );
2420        assert!(mode.validate_client_envelope(&current, &reserved).is_err());
2421        assert!(mode.validate_client_envelope(&current, &implicit).is_err());
2422    }
2423
2424    /// A `HandoffAccept` whose payload does not decode is deliberately **not**
2425    /// decided at the boundary — the hook returns `Ok` and `handle_message`
2426    /// rejects it `InvalidPayload` on its own grounds. Duplicating the
2427    /// decision here would only give two places to keep in sync.
2428    #[test]
2429    fn undecodable_handoff_accept_is_left_to_dispatch() {
2430        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2431        let mut session = base_session();
2432
2433        // A payload that is not a valid `HandoffAcceptPayload` (field 1 is a
2434        // string here, declared as a group-start wire type).
2435        let garbage = vec![0xffu8, 0xff, 0xff, 0xff];
2436        assert!(HandoffAcceptPayload::decode(&*garbage).is_err());
2437
2438        let e = env("target", "HandoffAccept", garbage);
2439        assert!(
2440            mode.validate_client_envelope(&session, &e).is_ok(),
2441            "the boundary must not decide an undecodable payload"
2442        );
2443
2444        // And dispatch still rejects it, so nothing is let through.
2445        let result = mode
2446            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2447            .unwrap();
2448        apply(&mut session, result);
2449        assert!(matches!(
2450            mode.on_message(&session, &e).unwrap_err(),
2451            MacpError::InvalidPayload
2452        ));
2453    }
2454
2455    /// Error ordering is unchanged: an envelope that is both unauthorized and
2456    /// carries a reserved id reports the **authorization** error. The hook
2457    /// runs after `authorize_sender` precisely so the pre-existing
2458    /// Forbidden-before-payload ordering does not shift at rev 2.
2459    #[test]
2460    fn client_boundary_runs_after_sender_authorization() {
2461        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2462        let mut session = base_session();
2463        let result = mode
2464            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2465            .unwrap();
2466        apply(&mut session, result);
2467
2468        // `stranger` is not a declared participant -> Forbidden from
2469        // `authorize_sender`, even though the id is reserved and the payload
2470        // sets `implicit`.
2471        let mut e = env(
2472            "stranger",
2473            "HandoffAccept",
2474            make_implicit_accept("h1", "stranger"),
2475        );
2476        e.message_id = reserved_id("h1");
2477        assert!(matches!(
2478            crate::step::validate_message(&session, &e, &mode).unwrap_err(),
2479            MacpError::Forbidden
2480        ));
2481
2482        // The same envelope from the authorized sender does reach the hook.
2483        let mut authorized = e.clone();
2484        authorized.sender = "target".into();
2485        assert!(matches!(
2486            crate::step::validate_message(&session, &authorized, &mode).unwrap_err(),
2487            MacpError::InvalidEnvelope
2488        ));
2489    }
2490
2491    // --- The synthesis contract: `due_synthetic_envelope` (Phase 11d) ---
2492    //
2493    // RFC-MACP-0010 §5.1(2)-(3). Nothing calls the hook yet — the kernel
2494    // wiring is 11e — so these drive it by hand with injected clocks.
2495
2496    /// A current-revision session with [`auto_accept_policy`] bound and one
2497    /// outstanding offer `h1` to `target`, accepted at [`OFFER_TIME_MS`].
2498    fn offered_session() -> (HandoffMode, Session) {
2499        let mode = HandoffMode::new(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator));
2500        let mut session = base_session();
2501        session.policy_definition = Some(auto_accept_policy());
2502        let result = mode
2503            .on_session_start(&session, &env("owner", "SessionStart", vec![]))
2504            .unwrap();
2505        apply(&mut session, result);
2506        let ctx = macp_core::mode::MessageContext::new(OFFER_TIME_MS);
2507        let result = mode
2508            .on_message_at(
2509                &session,
2510                &env("owner", "HandoffOffer", make_offer("h1", "target")),
2511                &ctx,
2512            )
2513            .unwrap();
2514        apply(&mut session, result);
2515        (mode, session)
2516    }
2517
2518    /// The bound timeout, read back through the production resolver rather
2519    /// than re-hard-coded from the policy JSON.
2520    fn bound_timeout_ms(session: &Session) -> i64 {
2521        let timeout = HandoffMode::implicit_accept_timeout_ms(session);
2522        assert_eq!(timeout, 100, "auto_accept_policy binds a 100ms timeout");
2523        timeout
2524    }
2525
2526    /// RFC-MACP-0010 §5.1(3): the synthetic accept becomes due exactly when
2527    /// the offer's timeout has elapsed in *unsuspended* time, and its
2528    /// `timestamp_unix_ms` is the computed deadline D — not the observation
2529    /// time, and not the naive
2530    /// `offered_at + timeout + banked_suspension_since_the_offer`.
2531    ///
2532    /// The last case is the one that separates the three: a pause that begins
2533    /// **after** D must not move D at all, while both wrong implementations
2534    /// move it.
2535    #[test]
2536    fn due_synthetic_envelope_emits_at_the_deadline() {
2537        let (mode, session) = offered_session();
2538        let timeout = bound_timeout_ms(&session);
2539        let deadline = OFFER_TIME_MS + timeout;
2540
2541        // Not due one millisecond early.
2542        assert!(mode
2543            .due_synthetic_envelope(&session, deadline - 1)
2544            .is_none());
2545
2546        // Due at D, and at every later observation — with the *same* envelope,
2547        // because nothing in it may depend on when it was asked.
2548        let at_deadline = mode
2549            .due_synthetic_envelope(&session, deadline)
2550            .expect("due at the deadline");
2551        let much_later = mode
2552            .due_synthetic_envelope(&session, deadline + 10_000)
2553            .expect("still due long after the deadline");
2554        assert_eq!(at_deadline, much_later);
2555
2556        // Field by field: the constants §5.1(3) fixes.
2557        assert_eq!(at_deadline.macp_version, "1.0");
2558        assert_eq!(at_deadline.mode, session.mode);
2559        assert_eq!(at_deadline.session_id, session.session_id);
2560        assert_eq!(at_deadline.message_type, "HandoffAccept");
2561        assert_eq!(at_deadline.message_id, "implicit-accept:h1");
2562        assert_eq!(at_deadline.sender, "target");
2563        assert_eq!(at_deadline.timestamp_unix_ms, deadline);
2564        let payload = HandoffAcceptPayload::decode(&*at_deadline.payload).unwrap();
2565        assert_eq!(payload.handoff_id, "h1");
2566        assert_eq!(payload.accepted_by, "target");
2567        assert_eq!(payload.reason, IMPLICIT_ACCEPT_REASON);
2568        assert!(payload.implicit);
2569
2570        // A pause *inside* the window pushes D out by its width (§5.1(1)):
2571        // 50ms of live time, a 150ms pause, then the remaining 50ms.
2572        let mut paused = session.clone();
2573        paused.suspend(OFFER_TIME_MS + 50).unwrap();
2574        paused.resume(OFFER_TIME_MS + 200).unwrap();
2575        assert_eq!(paused.suspension_intervals, vec![(1_050, 1_200)]);
2576        assert!(mode
2577            .due_synthetic_envelope(&paused, OFFER_TIME_MS + 249)
2578            .is_none());
2579        assert_eq!(
2580            mode.due_synthetic_envelope(&paused, OFFER_TIME_MS + 250)
2581                .expect("due at the walked deadline")
2582                .timestamp_unix_ms,
2583            OFFER_TIME_MS + 250
2584        );
2585
2586        // THE KILLER CASE — a pause that begins *after* D does not move D.
2587        // §5.1(3) counts "suspended time within the window"; this pause is
2588        // outside it. The offer's unsuspended time had already hit the timeout
2589        // at D, so D is what permanent history must record no matter how long
2590        // afterwards (or how many pauses later) the runtime gets round to
2591        // looking.
2592        let mut late_pause = session.clone();
2593        late_pause.suspend(deadline + 50).unwrap();
2594        late_pause.resume(deadline + 250).unwrap();
2595        let observed_at = deadline + 400;
2596        let late = mode
2597            .due_synthetic_envelope(&late_pause, observed_at)
2598            .expect("a pause after the deadline cannot un-due the accept");
2599        assert_eq!(
2600            late.timestamp_unix_ms, deadline,
2601            "a pause beginning after D must not move D"
2602        );
2603        // The two wrong implementations this pins out, named so a future
2604        // refactor cannot reintroduce either by accident:
2605        assert_ne!(
2606            late.timestamp_unix_ms, observed_at,
2607            "observation time is not the deadline"
2608        );
2609        assert_eq!(late_pause.accumulated_suspended_ms, 200);
2610        assert_ne!(
2611            late.timestamp_unix_ms,
2612            OFFER_TIME_MS + timeout + late_pause.accumulated_suspended_ms,
2613            "the naive offered_at + timeout + banked-suspension formula counts \
2614             pauses outside the window"
2615        );
2616    }
2617
2618    /// Everything that makes an implicit accept *not* due. Each arm is one
2619    /// guard in `due_synthetic_envelope`.
2620    #[test]
2621    fn due_synthetic_envelope_returns_none_unless_an_offer_is_due() {
2622        let (mode, session) = offered_session();
2623        let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2624        let long_after = deadline + 1_000_000;
2625
2626        // Rev gate: legacy sessions never synthesize — they keep resolving
2627        // through the interim in-`Commitment` path, so their histories replay
2628        // to the outcome they were accepted with.
2629        for rev in [0, 1] {
2630            let mut legacy = session.clone();
2631            legacy.semantics_rev = rev;
2632            assert!(
2633                mode.due_synthetic_envelope(&legacy, long_after).is_none(),
2634                "rev {rev} must never synthesize"
2635            );
2636        }
2637
2638        // No policy bound at all.
2639        let mut unbound = session.clone();
2640        unbound.policy_definition = None;
2641        assert!(mode.due_synthetic_envelope(&unbound, long_after).is_none());
2642
2643        // Policy bound but the feature switched off (timeout 0), and rules
2644        // that do not parse — both degrade to "never", matching the interim
2645        // path's `unwrap_or_default`.
2646        for rules in [
2647            serde_json::json!({ "acceptance": { "implicit_accept_timeout_ms": 0 } }),
2648            serde_json::json!({ "acceptance": "not an object" }),
2649        ] {
2650            let mut off = session.clone();
2651            off.policy_definition = Some(macp_core::policy::PolicyDefinition {
2652                policy_id: "off".into(),
2653                mode: "macp.mode.handoff.v1".into(),
2654                description: "no implicit accept".into(),
2655                rules,
2656                schema_version: 1,
2657            });
2658            assert_eq!(HandoffMode::implicit_accept_timeout_ms(&off), 0);
2659            assert!(mode.due_synthetic_envelope(&off, long_after).is_none());
2660        }
2661
2662        // No offer yet (session start only), and undecodable `mode_state`.
2663        let mut no_offer = session.clone();
2664        no_offer.mode_state = HandoffMode::encode_state(&HandoffState::default());
2665        assert!(mode.due_synthetic_envelope(&no_offer, long_after).is_none());
2666        let mut empty = session.clone();
2667        empty.mode_state = vec![];
2668        assert!(mode.due_synthetic_envelope(&empty, long_after).is_none());
2669        let mut garbage = session.clone();
2670        garbage.mode_state = b"not json".to_vec();
2671        assert!(mode.due_synthetic_envelope(&garbage, long_after).is_none());
2672
2673        // The offer is no longer outstanding.
2674        for disposition in [HandoffDisposition::Accepted, HandoffDisposition::Declined] {
2675            let mut settled = session.clone();
2676            let mut state: HandoffState = serde_json::from_slice(&settled.mode_state).unwrap();
2677            state.offers.get_mut("h1").unwrap().disposition = disposition;
2678            settled.mode_state = serde_json::to_vec(&state).unwrap();
2679            assert!(mode.due_synthetic_envelope(&settled, long_after).is_none());
2680        }
2681
2682        // A legacy offer with no acceptance clock recorded (unreachable at
2683        // rev >= 2, guarded anyway).
2684        let mut clockless = session.clone();
2685        let mut state: HandoffState = serde_json::from_slice(&clockless.mode_state).unwrap();
2686        state.offers.get_mut("h1").unwrap().offered_at_ms = 0;
2687        clockless.mode_state = serde_json::to_vec(&state).unwrap();
2688        assert!(mode
2689            .due_synthetic_envelope(&clockless, long_after)
2690            .is_none());
2691    }
2692
2693    /// The synthetic payload's prost encoding, pinned byte-for-byte. It is
2694    /// written into permanent history, so a field reordering or a changed
2695    /// `reason` would silently break byte-identical replay of every log that
2696    /// carries one — and Phase 12's byte-identity criterion outright.
2697    #[test]
2698    fn synthetic_payload_bytes_are_pinned() {
2699        let (mode, session) = offered_session();
2700        let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2701        let synthetic = mode
2702            .due_synthetic_envelope(&session, deadline)
2703            .expect("due at the deadline");
2704
2705        let expected: Vec<u8> = [
2706            b"\x0a\x02h1".as_slice(),             // 1: handoff_id
2707            b"\x12\x06target",                    // 2: accepted_by
2708            b"\x1a\x19implicit accept (timeout)", // 3: reason
2709            b"\x20\x01",                          // 4: implicit = true
2710        ]
2711        .concat();
2712        assert_eq!(synthetic.payload, expected);
2713    }
2714
2715    /// Dispatch accepts the synthetic envelope at rev >= 2 — this is what
2716    /// replay does with the recorded entry — and applies exactly the mutation
2717    /// the interim in-`Commitment` path applies, so `mode_state` is
2718    /// byte-identical either way.
2719    ///
2720    /// Rejected when malformed in any single field, and at rev 1.
2721    #[test]
2722    fn implicit_accept_dispatch_accepted_at_rev2() {
2723        let (mode, session) = offered_session();
2724        let deadline = OFFER_TIME_MS + bound_timeout_ms(&session);
2725        let synthetic = mode
2726            .due_synthetic_envelope(&session, deadline)
2727            .expect("due at the deadline");
2728        // Replay dispatches the entry with `accepted_at_ms == received_at_ms`,
2729        // which for this entry is D.
2730        let ctx = macp_core::mode::MessageContext::new(synthetic.timestamp_unix_ms);
2731
2732        let mut accepted = session.clone();
2733        let resp = mode.on_message_at(&accepted, &synthetic, &ctx).unwrap();
2734        apply(&mut accepted, resp);
2735        let state: HandoffState = serde_json::from_slice(&accepted.mode_state).unwrap();
2736        let offer = &state.offers["h1"];
2737        assert_eq!(offer.disposition, HandoffDisposition::Accepted);
2738        assert_eq!(offer.accepted_by.as_deref(), Some("target"));
2739        assert_eq!(
2740            offer.outcome_reason.as_deref(),
2741            Some(IMPLICIT_ACCEPT_REASON)
2742        );
2743
2744        // ... and the commitment the synthesis exists to unblock now resolves.
2745        let commit = mode
2746            .on_message_at(
2747                &accepted,
2748                &env("owner", "Commitment", commitment_payload()),
2749                &macp_core::mode::MessageContext::new(deadline + 10),
2750            )
2751            .unwrap();
2752        assert!(matches!(commit, ModeResponse::PersistAndResolve { .. }));
2753
2754        // Idempotent: nothing is due once the offer is settled, and the same
2755        // entry cannot be applied twice.
2756        assert!(mode
2757            .due_synthetic_envelope(&accepted, deadline + 10_000)
2758            .is_none());
2759        assert!(matches!(
2760            mode.on_message_at(&accepted, &synthetic, &ctx).unwrap_err(),
2761            MacpError::InvalidPayload
2762        ));
2763
2764        // One field off the synthetic shape is one rejection. Each of these
2765        // would otherwise be an envelope a library caller could hand-build.
2766        let mut wrong_sender = synthetic.clone();
2767        wrong_sender.sender = "owner".into();
2768        assert!(matches!(
2769            mode.on_message_at(&session, &wrong_sender, &ctx)
2770                .unwrap_err(),
2771            MacpError::Forbidden
2772        ));
2773
2774        let mut wrong_accepted_by = synthetic.clone();
2775        wrong_accepted_by.payload = make_implicit_accept("h1", "owner");
2776        assert!(matches!(
2777            mode.on_message_at(&session, &wrong_accepted_by, &ctx)
2778                .unwrap_err(),
2779            MacpError::InvalidPayload
2780        ));
2781
2782        let mut empty_accepted_by = synthetic.clone();
2783        empty_accepted_by.payload = make_implicit_accept("h1", "");
2784        assert!(
2785            matches!(
2786                mode.on_message_at(&session, &empty_accepted_by, &ctx)
2787                    .unwrap_err(),
2788                MacpError::InvalidPayload
2789            ),
2790            "stricter than the explicit arm: the synthetic always names the target"
2791        );
2792
2793        let mut wrong_id = synthetic.clone();
2794        wrong_id.message_id = "accept-1".into();
2795        assert!(matches!(
2796            mode.on_message_at(&session, &wrong_id, &ctx).unwrap_err(),
2797            MacpError::InvalidPayload
2798        ));
2799
2800        let mut unknown_offer = synthetic.clone();
2801        unknown_offer.message_id = reserved_id("h2");
2802        unknown_offer.payload = make_implicit_accept("h2", "target");
2803        assert!(matches!(
2804            mode.on_message_at(&session, &unknown_offer, &ctx)
2805                .unwrap_err(),
2806            MacpError::InvalidPayload
2807        ));
2808
2809        // Rev 1 refuses the identical, perfectly-formed envelope.
2810        let mut legacy = session.clone();
2811        legacy.semantics_rev = 1;
2812        assert!(matches!(
2813            mode.on_message_at(&legacy, &synthetic, &ctx).unwrap_err(),
2814            MacpError::InvalidPayload
2815        ));
2816    }
2817
2818    /// The negative rule of this phase, made killable instead of merely
2819    /// absent: **dispatch must not re-verify the deadline arithmetic.**
2820    ///
2821    /// This reproduces the exact replay state that a re-check would fail on. A
2822    /// pause runs from D+50 to D+250, *after* the deadline, so by the time the
2823    /// synthetic entry is dispatched on replay the session has already banked
2824    /// 200ms of suspension — because the `SessionSuspend`/`SessionResume`
2825    /// entries sit earlier in the log than the synthetic entry does (whose
2826    /// `received_at_ms` is D). Dispatch sees `accepted_at_ms = D` together with
2827    /// `accumulated_suspended_ms = 200`, so
2828    /// `implicit_accept_elapsed_ms(session, offer, D)` is **negative** — a
2829    /// re-check would reject a correctly-emitted entry and fail replay of a
2830    /// valid log.
2831    ///
2832    /// Add a time check to `dispatch_implicit_accept` and this test goes red.
2833    #[test]
2834    fn implicit_accept_dispatch_does_not_reverify_the_deadline() {
2835        let (mode, session) = offered_session();
2836        let timeout = bound_timeout_ms(&session);
2837        let deadline = OFFER_TIME_MS + timeout;
2838        let synthetic = mode
2839            .due_synthetic_envelope(&session, deadline)
2840            .expect("due at the deadline");
2841        assert_eq!(synthetic.timestamp_unix_ms, deadline);
2842
2843        // The pause that lands after D, banked before the synthetic entry is
2844        // replayed.
2845        let mut replayed = session.clone();
2846        replayed.suspend(deadline + 50).unwrap();
2847        replayed.resume(deadline + 250).unwrap();
2848        assert_eq!(replayed.accumulated_suspended_ms, 200);
2849
2850        // What a re-check would compute at the replay clock: less than nothing.
2851        let state: HandoffState = serde_json::from_slice(&replayed.mode_state).unwrap();
2852        assert!(
2853            HandoffMode::implicit_accept_elapsed_ms(&replayed, &state.offers["h1"], deadline)
2854                < timeout,
2855            "the scalar is below the timeout here — that is the whole hazard"
2856        );
2857
2858        // Dispatch accepts anyway, because it validates identity and shape
2859        // only.
2860        let ctx = macp_core::mode::MessageContext::new(synthetic.timestamp_unix_ms);
2861        let resp = mode.on_message_at(&replayed, &synthetic, &ctx).unwrap();
2862        apply(&mut replayed, resp);
2863        let state: HandoffState = serde_json::from_slice(&replayed.mode_state).unwrap();
2864        assert_eq!(
2865            state.offers["h1"].disposition,
2866            HandoffDisposition::Accepted,
2867            "a correctly-emitted synthetic entry must replay through dispatch"
2868        );
2869    }
2870}