Skip to main content

net/adapter/net/compute/
migration.rs

1//! Stateful daemon migration.
2//!
3//! Migration uses L4 `StateSnapshot` to move a daemon between nodes while
4//! preserving causal chain continuity. The process is a 6-phase state machine.
5
6use crate::adapter::net::state::snapshot::StateSnapshot;
7
8/// Subprotocol ID for migration control messages.
9pub const SUBPROTOCOL_MIGRATION: u16 = 0x0500;
10
11/// Phases of daemon migration.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MigrationPhase {
14    /// Take snapshot on source node.
15    Snapshot,
16    /// Transfer snapshot to target node.
17    Transfer,
18    /// Restore daemon on target, start buffering events.
19    Restore,
20    /// Replay buffered events on target.
21    Replay,
22    /// Atomic routing cutover: new events go to target.
23    Cutover,
24    /// Cleanup source.
25    Complete,
26}
27
28/// Structured reason the migration target rejected (or the
29/// orchestrator aborted) a migration. Replaces the free-form
30/// `reason: String` on `MigrationMessage::MigrationFailed` so the
31/// source can dispatch programmatically on the cause — specifically,
32/// distinguish "retry this, the target is still booting" (`NotReady`)
33/// from "give up, the target doesn't know this daemon kind"
34/// (`FactoryNotFound`).
35///
36/// See [`DAEMON_RUNTIME_READINESS_PLAN.md`](../../../../docs/DAEMON_RUNTIME_READINESS_PLAN.md)
37/// for the retry-classification table.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum MigrationFailureReason {
40    /// Target runtime exists but hasn't called `start()` yet — the
41    /// dispatcher received the migration before the runtime was
42    /// ready to accept one. **Retriable**: source should back off
43    /// + resend.
44    NotReady,
45    /// Target has no factory registered for the daemon's
46    /// `origin_hash` (supplied in the outer `MigrationFailed`
47    /// envelope). **Terminal** — retrying won't help; the target
48    /// is mis-configured (wrong node), the kind is wrong, or the
49    /// daemon never registered.
50    FactoryNotFound,
51    /// Target doesn't run a compute runtime at all (a bare `Mesh`
52    /// with no `DaemonRuntime` attached). **Terminal** — source
53    /// should pick a different target.
54    ComputeNotSupported,
55    /// Generic snapshot / restore / state-machine failure. Carries
56    /// a human-readable detail. **Terminal.**
57    StateFailed(String),
58    /// A migration is already in flight for the same origin.
59    /// **Terminal** on the duplicate attempt — caller should not
60    /// retry, and the currently-active migration should be allowed
61    /// to run to completion.
62    AlreadyMigrating,
63    /// Identity envelope failure: signature didn't verify, seal
64    /// open failed, etc. **Terminal** — tampering or misconfigured
65    /// target X25519 key; retry won't fix it.
66    IdentityTransportFailed(String),
67    /// Source gave up after exhausting its `NotReady` retry budget.
68    /// **Terminal** on both sides; carries the retry attempt count
69    /// for operator diagnosis.
70    NotReadyTimeout {
71        /// Number of `NotReady` retries the source attempted before
72        /// giving up. ≥ 1 because the first attempt always counts.
73        attempts: u8,
74    },
75}
76
77impl MigrationFailureReason {
78    /// `true` iff the source should retry after a short backoff
79    /// when it sees this reason. Today only `NotReady` qualifies —
80    /// the others are terminal.
81    pub fn is_retriable(&self) -> bool {
82        matches!(self, MigrationFailureReason::NotReady)
83    }
84
85    /// 16-bit wire code. Separating the code from the payload lets
86    /// the dispatcher's decoder match on the tag cheaply and the
87    /// payload length on-line with the variant.
88    pub fn code(&self) -> u16 {
89        match self {
90            MigrationFailureReason::NotReady => 0,
91            MigrationFailureReason::FactoryNotFound => 1,
92            MigrationFailureReason::ComputeNotSupported => 2,
93            MigrationFailureReason::StateFailed(_) => 3,
94            MigrationFailureReason::AlreadyMigrating => 4,
95            MigrationFailureReason::IdentityTransportFailed(_) => 5,
96            MigrationFailureReason::NotReadyTimeout { .. } => 6,
97        }
98    }
99}
100
101impl std::fmt::Display for MigrationFailureReason {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Self::NotReady => write!(f, "target runtime not ready yet"),
105            Self::FactoryNotFound => {
106                write!(f, "no factory registered on target for this daemon")
107            }
108            Self::ComputeNotSupported => {
109                write!(f, "target does not run a compute runtime")
110            }
111            Self::StateFailed(msg) => write!(f, "state failed: {msg}"),
112            Self::AlreadyMigrating => write!(f, "daemon is already migrating"),
113            Self::IdentityTransportFailed(msg) => {
114                write!(f, "identity envelope transport failed: {msg}")
115            }
116            Self::NotReadyTimeout { attempts } => {
117                write!(f, "source gave up after {attempts} NotReady retries")
118            }
119        }
120    }
121}
122
123/// State of an in-progress migration.
124pub struct MigrationState {
125    /// Origin hash of the daemon being migrated.
126    daemon_origin: u64,
127    /// Source node ID.
128    source_node: u64,
129    /// Target node ID.
130    target_node: u64,
131    /// Current phase (only mutable through transition methods).
132    phase: MigrationPhase,
133    /// Snapshot taken from source (set in Snapshot phase).
134    snapshot: Option<StateSnapshot>,
135    /// Monotonic instant when migration started. Pre-fix this
136    /// was a `u64` of wall-clock nanoseconds, and `elapsed_ms`
137    /// did `current_timestamp().saturating_sub(self.started_at)`
138    /// — a wall-clock jump backward (NTP step, manual `date`,
139    /// VM resume to an earlier moment) would saturate to `0`
140    /// and report the migration as instantaneous, masking
141    /// long-running stalls in operator dashboards. `Instant` is
142    /// monotonic by contract and is unaffected by clock jumps.
143    started_at: std::time::Instant,
144    /// Monotonic instant when the current phase was entered.
145    /// Used by operator tooling (Deck MIGRATIONS tab) to
146    /// distinguish "stuck in Replay for 30 minutes" from
147    /// "migration started 30 minutes ago" — the latter is
148    /// expected for large daemons, the former signals a stall.
149    phase_entered_at: std::time::Instant,
150    /// Number of retry attempts this migration has accumulated.
151    /// Bumped by callers that re-drive a failed step via
152    /// [`Self::bump_retry`]; surfaced through the migration
153    /// snapshot so operators can spot migrations cycling
154    /// through transient failures without dropping into the
155    /// per-daemon log.
156    retry_count: u32,
157}
158
159impl MigrationState {
160    /// Create a new migration.
161    pub fn new(daemon_origin: u64, source_node: u64, target_node: u64) -> Self {
162        let now = std::time::Instant::now();
163        Self {
164            daemon_origin,
165            source_node,
166            target_node,
167            phase: MigrationPhase::Snapshot,
168            snapshot: None,
169            started_at: now,
170            phase_entered_at: now,
171            retry_count: 0,
172        }
173    }
174
175    /// Set the snapshot and advance to Transfer phase.
176    pub fn set_snapshot(&mut self, snapshot: StateSnapshot) -> Result<(), MigrationError> {
177        if self.phase != MigrationPhase::Snapshot {
178            return Err(MigrationError::WrongPhase {
179                expected: MigrationPhase::Snapshot,
180                got: self.phase,
181            });
182        }
183        // Validate snapshot belongs to the daemon being migrated
184        if snapshot.entity_id.origin_hash() != self.daemon_origin {
185            return Err(MigrationError::StateFailed(format!(
186                "snapshot origin {:#x} does not match daemon {:#x}",
187                snapshot.entity_id.origin_hash(),
188                self.daemon_origin,
189            )));
190        }
191        self.snapshot = Some(snapshot);
192        self.phase = MigrationPhase::Transfer;
193        self.phase_entered_at = std::time::Instant::now();
194        Ok(())
195    }
196
197    /// Mark transfer complete, advance to Restore.
198    pub fn transfer_complete(&mut self) -> Result<(), MigrationError> {
199        if self.phase != MigrationPhase::Transfer {
200            return Err(MigrationError::WrongPhase {
201                expected: MigrationPhase::Transfer,
202                got: self.phase,
203            });
204        }
205        self.phase = MigrationPhase::Restore;
206        self.phase_entered_at = std::time::Instant::now();
207        Ok(())
208    }
209
210    /// Mark restore complete, advance to Replay.
211    pub fn restore_complete(&mut self) -> Result<(), MigrationError> {
212        if self.phase != MigrationPhase::Restore {
213            return Err(MigrationError::WrongPhase {
214                expected: MigrationPhase::Restore,
215                got: self.phase,
216            });
217        }
218        self.phase = MigrationPhase::Replay;
219        self.phase_entered_at = std::time::Instant::now();
220        Ok(())
221    }
222
223    /// Mark replay complete, advance to Cutover.
224    pub fn replay_complete(&mut self) -> Result<(), MigrationError> {
225        if self.phase != MigrationPhase::Replay {
226            return Err(MigrationError::WrongPhase {
227                expected: MigrationPhase::Replay,
228                got: self.phase,
229            });
230        }
231        self.phase = MigrationPhase::Cutover;
232        self.phase_entered_at = std::time::Instant::now();
233        Ok(())
234    }
235
236    /// Mark cutover complete, advance to Complete.
237    pub fn cutover_complete(&mut self) -> Result<(), MigrationError> {
238        if self.phase != MigrationPhase::Cutover {
239            return Err(MigrationError::WrongPhase {
240                expected: MigrationPhase::Cutover,
241                got: self.phase,
242            });
243        }
244        self.phase = MigrationPhase::Complete;
245        self.phase_entered_at = std::time::Instant::now();
246        Ok(())
247    }
248
249    /// Force the phase to a specific value without validation.
250    ///
251    /// Used for multi-chunk snapshots where the orchestrator needs to advance
252    /// past Snapshot without having the full snapshot for `set_snapshot()`.
253    /// The target will validate the reassembled snapshot.
254    pub(crate) fn force_phase(&mut self, phase: MigrationPhase) {
255        self.phase = phase;
256        self.phase_entered_at = std::time::Instant::now();
257    }
258
259    /// Check if migration is finished.
260    pub fn is_complete(&self) -> bool {
261        self.phase == MigrationPhase::Complete
262    }
263
264    /// Elapsed time in milliseconds since the migration was
265    /// constructed. Backed by a monotonic `Instant`, so a system
266    /// clock that jumps backward (NTP step, VM resume) does not
267    /// reset this to `0`; long-running migrations stay observable
268    /// in operator dashboards.
269    pub fn elapsed_ms(&self) -> u64 {
270        u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
271    }
272
273    /// Milliseconds since the current phase was entered. Same
274    /// monotonic source as [`Self::elapsed_ms`]; resets on
275    /// every phase transition.
276    pub fn age_in_phase_ms(&self) -> u64 {
277        u64::try_from(self.phase_entered_at.elapsed().as_millis()).unwrap_or(u64::MAX)
278    }
279
280    /// Number of retries this migration has accumulated.
281    #[inline]
282    pub fn retry_count(&self) -> u32 {
283        self.retry_count
284    }
285
286    /// Increment the retry counter. Saturating at `u32::MAX`
287    /// so a stuck retry loop never wraps to `0` and masks the
288    /// retry pressure from operator dashboards.
289    pub fn bump_retry(&mut self) {
290        self.retry_count = self.retry_count.saturating_add(1);
291    }
292
293    /// Payload byte count of the snapshot once set; `None`
294    /// while the snapshot phase is still in progress (or
295    /// when the orchestrator advanced past Snapshot via
296    /// `force_phase` without calling `set_snapshot`).
297    pub fn snapshot_size_bytes(&self) -> Option<u64> {
298        self.snapshot.as_ref().map(|s| s.state.len() as u64)
299    }
300
301    /// Get the daemon origin hash.
302    #[inline]
303    pub fn daemon_origin(&self) -> u64 {
304        self.daemon_origin
305    }
306
307    /// Get the source node ID.
308    #[inline]
309    pub fn source_node(&self) -> u64 {
310        self.source_node
311    }
312
313    /// Get the target node ID.
314    #[inline]
315    pub fn target_node(&self) -> u64 {
316        self.target_node
317    }
318
319    /// Get the current phase.
320    #[inline]
321    pub fn phase(&self) -> MigrationPhase {
322        self.phase
323    }
324
325    /// Get the snapshot (if taken).
326    #[inline]
327    pub fn snapshot(&self) -> Option<&StateSnapshot> {
328        self.snapshot.as_ref()
329    }
330}
331
332impl std::fmt::Debug for MigrationState {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        f.debug_struct("MigrationState")
335            .field("daemon", &format!("{:#x}", self.daemon_origin))
336            .field("source", &format!("{:#x}", self.source_node))
337            .field("target", &format!("{:#x}", self.target_node))
338            .field("phase", &self.phase)
339            .field("has_snapshot", &self.snapshot.is_some())
340            .finish()
341    }
342}
343
344/// Errors from migration operations.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum MigrationError {
347    /// Daemon not registered locally.
348    DaemonNotFound(u64),
349    /// Target node unreachable or refused.
350    TargetUnavailable(u64),
351    /// Auto-placement found no candidate node satisfying the
352    /// daemon's capability requirements. Distinct from
353    /// `TargetUnavailable(_)` which carries a specific failed
354    /// target — auto-placement never has one to report. Pre-fix
355    /// the auto path constructed `TargetUnavailable(0)`,
356    /// surfacing "target node 0x0 unavailable" to operators when
357    /// no specific node had ever been tried.
358    NoTargetAvailable,
359    /// Snapshot/restore failure.
360    StateFailed(String),
361    /// Migration already in progress for this daemon.
362    AlreadyMigrating(u64),
363    /// Attempted to advance from wrong phase.
364    WrongPhase {
365        /// Expected phase.
366        expected: MigrationPhase,
367        /// Actual phase.
368        got: MigrationPhase,
369    },
370    /// Snapshot exceeds the maximum transferable size.
371    SnapshotTooLarge {
372        /// Actual size in bytes.
373        size: usize,
374        /// Maximum allowed size in bytes.
375        max: usize,
376    },
377    /// Wire-driven event-buffering surface refused an insert because
378    /// the per-daemon out-of-order pending buffer is at its byte or
379    /// event-count cap. Source must back off; the migration is not
380    /// failed but the offending event was not accepted.
381    BufferFull {
382        /// Current buffered event count.
383        events: usize,
384        /// Current buffered byte total (sum of payload lengths).
385        bytes: usize,
386    },
387    /// Inbound migration message arrived from a peer that is not the
388    /// recorded principal for this migration's role. The wire layer
389    /// authenticates the session; this layer authenticates that the
390    /// authenticated peer is actually a participant in the recorded
391    /// migration (source / target / orchestrator). Mismatches are
392    /// silently dropped at the dispatch boundary rather than acted on.
393    WrongPeer {
394        /// The daemon-origin the migration is keyed on.
395        daemon_origin: u64,
396        /// The peer that delivered the message.
397        from: u64,
398        /// The principal recorded for this role.
399        expected: u64,
400    },
401}
402
403impl std::fmt::Display for MigrationError {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            Self::DaemonNotFound(id) => write!(f, "daemon {:#x} not found", id),
407            Self::TargetUnavailable(id) => write!(f, "target node {:#x} unavailable", id),
408            Self::NoTargetAvailable => {
409                write!(
410                    f,
411                    "no candidate node satisfies the daemon's capability requirements"
412                )
413            }
414            Self::StateFailed(msg) => write!(f, "state operation failed: {}", msg),
415            Self::AlreadyMigrating(id) => write!(f, "daemon {:#x} already migrating", id),
416            Self::WrongPhase { expected, got } => {
417                write!(
418                    f,
419                    "wrong migration phase: expected {:?}, got {:?}",
420                    expected, got
421                )
422            }
423            Self::SnapshotTooLarge { size, max } => {
424                write!(
425                    f,
426                    "snapshot too large: {} bytes exceeds max {} bytes",
427                    size, max
428                )
429            }
430            Self::BufferFull { events, bytes } => {
431                write!(
432                    f,
433                    "migration buffer full: {} events / {} bytes",
434                    events, bytes
435                )
436            }
437            Self::WrongPeer {
438                daemon_origin,
439                from,
440                expected,
441            } => {
442                write!(
443                    f,
444                    "migration {:#x}: peer {:#x} not the recorded principal {:#x}",
445                    daemon_origin, from, expected
446                )
447            }
448        }
449    }
450}
451
452impl std::error::Error for MigrationError {}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::adapter::net::state::causal::CausalLink;
458    use bytes::Bytes;
459
460    #[test]
461    fn test_migration_phase_progression() {
462        let kp = crate::adapter::net::identity::EntityKeypair::generate();
463        let origin = kp.origin_hash();
464        let mut state = MigrationState::new(origin, 0x1111, 0x2222);
465        assert_eq!(state.phase(), MigrationPhase::Snapshot);
466
467        // Can't skip phases
468        assert!(state.transfer_complete().is_err());
469
470        // Normal progression
471        let snapshot = StateSnapshot::new(
472            kp.entity_id().clone(),
473            CausalLink::genesis(origin, 0),
474            Bytes::from_static(b"state"),
475            crate::adapter::net::state::horizon::ObservedHorizon::new(),
476        );
477
478        state.set_snapshot(snapshot).unwrap();
479        assert_eq!(state.phase(), MigrationPhase::Transfer);
480
481        state.transfer_complete().unwrap();
482        assert_eq!(state.phase(), MigrationPhase::Restore);
483
484        state.restore_complete().unwrap();
485        assert_eq!(state.phase(), MigrationPhase::Replay);
486
487        state.replay_complete().unwrap();
488        assert_eq!(state.phase(), MigrationPhase::Cutover);
489
490        state.cutover_complete().unwrap();
491        assert_eq!(state.phase(), MigrationPhase::Complete);
492        assert!(state.is_complete());
493    }
494
495    #[test]
496    fn test_wrong_phase_error() {
497        let mut state = MigrationState::new(0xAAAA, 0x1111, 0x2222);
498
499        let err = state.restore_complete().unwrap_err();
500        assert_eq!(
501            err,
502            MigrationError::WrongPhase {
503                expected: MigrationPhase::Restore,
504                got: MigrationPhase::Snapshot,
505            }
506        );
507    }
508
509    /// Pin: only `NotReady` is retriable. The rest are terminal.
510    /// A regression that marks `FactoryNotFound` (or any other
511    /// terminal reason) as retriable would cause the source to
512    /// retry forever, waiting for a daemon that will never be
513    /// registered — silent migration hang.
514    #[test]
515    fn migration_failure_reason_is_retriable_only_for_not_ready() {
516        assert!(MigrationFailureReason::NotReady.is_retriable());
517
518        // All other variants must be terminal.
519        assert!(!MigrationFailureReason::FactoryNotFound.is_retriable());
520        assert!(!MigrationFailureReason::ComputeNotSupported.is_retriable());
521        assert!(!MigrationFailureReason::StateFailed("x".into()).is_retriable());
522        assert!(!MigrationFailureReason::AlreadyMigrating.is_retriable());
523        assert!(!MigrationFailureReason::IdentityTransportFailed("x".into()).is_retriable());
524        assert!(!MigrationFailureReason::NotReadyTimeout { attempts: 5 }.is_retriable());
525    }
526
527    /// Pin: each variant has a distinct 16-bit wire code. A
528    /// collision (two variants returning the same code) would
529    /// silently mis-decode on the receiver — the dispatcher's
530    /// payload-length-by-tag logic depends on tag uniqueness.
531    /// Also pin the specific code values so a re-order of the
532    /// enum doesn't silently re-number them (which would break
533    /// every existing peer parsing the wire format).
534    #[test]
535    fn migration_failure_reason_code_is_stable_and_unique() {
536        let variants = [
537            (MigrationFailureReason::NotReady, 0u16),
538            (MigrationFailureReason::FactoryNotFound, 1),
539            (MigrationFailureReason::ComputeNotSupported, 2),
540            (MigrationFailureReason::StateFailed("x".into()), 3),
541            (MigrationFailureReason::AlreadyMigrating, 4),
542            (
543                MigrationFailureReason::IdentityTransportFailed("y".into()),
544                5,
545            ),
546            (MigrationFailureReason::NotReadyTimeout { attempts: 1 }, 6),
547        ];
548        for (reason, expected_code) in &variants {
549            assert_eq!(
550                reason.code(),
551                *expected_code,
552                "wire code drift for {reason:?}",
553            );
554        }
555        // Pairwise uniqueness.
556        let codes: Vec<u16> = variants.iter().map(|(r, _)| r.code()).collect();
557        let mut sorted = codes.clone();
558        sorted.sort_unstable();
559        sorted.dedup();
560        assert_eq!(codes.len(), sorted.len(), "wire-code collision: {codes:?}",);
561    }
562
563    /// Pin: every MigrationFailureReason Display message names the
564    /// failure class so operators can triage from a single log
565    /// line. A refactor that drops the variant name from the
566    /// message would force consumers to look at the wire code
567    /// instead.
568    #[test]
569    fn migration_failure_reason_display_covers_every_variant() {
570        assert_eq!(
571            format!("{}", MigrationFailureReason::NotReady),
572            "target runtime not ready yet"
573        );
574        assert_eq!(
575            format!("{}", MigrationFailureReason::FactoryNotFound),
576            "no factory registered on target for this daemon"
577        );
578        assert_eq!(
579            format!("{}", MigrationFailureReason::ComputeNotSupported),
580            "target does not run a compute runtime"
581        );
582        assert_eq!(
583            format!("{}", MigrationFailureReason::StateFailed("boom".into())),
584            "state failed: boom"
585        );
586        assert_eq!(
587            format!("{}", MigrationFailureReason::AlreadyMigrating),
588            "daemon is already migrating"
589        );
590        assert_eq!(
591            format!(
592                "{}",
593                MigrationFailureReason::IdentityTransportFailed("seal failed".into())
594            ),
595            "identity envelope transport failed: seal failed"
596        );
597        assert_eq!(
598            format!(
599                "{}",
600                MigrationFailureReason::NotReadyTimeout { attempts: 7 }
601            ),
602            "source gave up after 7 NotReady retries"
603        );
604    }
605
606    /// The existing `test_wrong_phase_error` pins `restore_complete`
607    /// rejecting Snapshot-state. The peer phase-advance methods
608    /// (`transfer_complete`, `replay_complete`, `cutover_complete`)
609    /// also gate on phase, but only restore_complete's reject
610    /// branch was exercised — the codecov gap at L226-L229 and
611    /// L239-L242 was the WrongPhase construction inside the other
612    /// methods. Pin them so a future refactor that loosens any
613    /// individual guard can't drop a phase silently.
614    #[test]
615    fn phase_advance_methods_reject_when_not_in_prerequisite_phase() {
616        let mut state = MigrationState::new(0xAAAA, 0x1111, 0x2222);
617
618        // Fresh state is in Snapshot — none of these may advance.
619        assert!(matches!(
620            state.transfer_complete(),
621            Err(MigrationError::WrongPhase {
622                expected: MigrationPhase::Transfer,
623                got: MigrationPhase::Snapshot
624            })
625        ));
626        assert!(matches!(
627            state.replay_complete(),
628            Err(MigrationError::WrongPhase {
629                expected: MigrationPhase::Replay,
630                got: MigrationPhase::Snapshot
631            })
632        ));
633        assert!(matches!(
634            state.cutover_complete(),
635            Err(MigrationError::WrongPhase {
636                expected: MigrationPhase::Cutover,
637                got: MigrationPhase::Snapshot
638            })
639        ));
640    }
641
642    // ---- Regression tests for Cubic AI findings ----
643
644    #[test]
645    fn test_regression_set_snapshot_rejects_wrong_origin() {
646        // Regression: set_snapshot accepted snapshots from any entity,
647        // allowing migration to bind state from the wrong daemon.
648        let kp = crate::adapter::net::identity::EntityKeypair::generate();
649        let wrong_origin = kp.origin_hash();
650
651        // Migration is for daemon 0xBBBB, but snapshot is for kp's origin
652        let mut state = MigrationState::new(0xBBBB, 0x1111, 0x2222);
653
654        let snapshot = StateSnapshot::new(
655            kp.entity_id().clone(),
656            CausalLink::genesis(wrong_origin, 0),
657            Bytes::from_static(b"state"),
658            crate::adapter::net::state::horizon::ObservedHorizon::new(),
659        );
660
661        assert!(
662            state.set_snapshot(snapshot).is_err(),
663            "set_snapshot must reject snapshot from a different daemon"
664        );
665    }
666
667    /// Source pin: `started_at` must be a monotonic `Instant`,
668    /// not a wall-clock `u64` of nanoseconds. The pre-fix shape
669    /// stored `current_timestamp()` (UNIX-epoch nanos) and
670    /// computed `elapsed_ms` as `current_timestamp().saturating_sub(started_at)
671    /// / 1_000_000`. A wall-clock jump backward (NTP step,
672    /// manual `date` set, VM resume to an earlier moment)
673    /// would saturate the subtraction to `0` and report a long
674    /// migration as instantaneous, masking stalls in operator
675    /// dashboards.
676    ///
677    /// We can't simulate a clock jump in a unit test, so this
678    /// test pins the shape: the field must be an `Instant`, and
679    /// `elapsed_ms` must derive from `started_at.elapsed()` —
680    /// which is monotonic by contract. A revert to `u64` plus
681    /// `current_timestamp().saturating_sub(...)` re-introduces
682    /// the hazard and is rejected here.
683    #[test]
684    fn started_at_must_be_monotonic_instant_not_wall_clock_u64() {
685        let src = include_str!("migration.rs");
686
687        // Locate the `started_at` field declaration inside
688        // `pub struct MigrationState { ... }`.
689        let struct_marker = "pub struct MigrationState";
690        let struct_start = src
691            .find(struct_marker)
692            .expect("MigrationState struct must exist");
693        // The struct body ends at the next `}` at column 0 (or
694        // before the next top-level `impl`/`pub`).
695        let struct_end_offset = src[struct_start..]
696            .find("\n}\n")
697            .expect("struct body must terminate with `}`")
698            + struct_start;
699        let struct_body = &src[struct_start..struct_end_offset];
700
701        let body_no_comments: String = struct_body
702            .lines()
703            .map(|l| match l.find("//") {
704                Some(idx) => &l[..idx],
705                None => l,
706            })
707            .collect::<Vec<_>>()
708            .join("\n");
709
710        assert!(
711            body_no_comments.contains("started_at: std::time::Instant"),
712            "regression: MigrationState.started_at must be a \
713             monotonic `std::time::Instant`. A `u64` of wall-clock \
714             nanoseconds is unsafe — a system clock that steps \
715             backward (NTP / VM resume / manual `date`) saturates \
716             elapsed_ms to 0 and masks long-running stalls."
717        );
718        assert!(
719            !body_no_comments.contains("started_at: u64"),
720            "regression: MigrationState.started_at must not be a \
721             `u64` wall-clock timestamp."
722        );
723
724        // `elapsed_ms` must derive from `started_at.elapsed()`,
725        // not from `current_timestamp().saturating_sub(...)`.
726        let elapsed_marker = "pub fn elapsed_ms(";
727        let elapsed_start = src.find(elapsed_marker).expect("elapsed_ms must exist");
728        let elapsed_end_offset = src[elapsed_start..]
729            .find("\n    }")
730            .expect("elapsed_ms body must terminate")
731            + elapsed_start;
732        let elapsed_body = &src[elapsed_start..elapsed_end_offset];
733
734        let elapsed_no_comments: String = elapsed_body
735            .lines()
736            .map(|l| match l.find("//") {
737                Some(idx) => &l[..idx],
738                None => l,
739            })
740            .collect::<Vec<_>>()
741            .join("\n");
742
743        assert!(
744            elapsed_no_comments.contains("self.started_at.elapsed()"),
745            "regression: elapsed_ms must derive from \
746             `self.started_at.elapsed()` to stay monotonic. \
747             Using `current_timestamp().saturating_sub(self.started_at)` \
748             reintroduces the wall-clock-jump-saturates-to-zero hazard."
749        );
750        assert!(
751            !elapsed_no_comments.contains("current_timestamp()"),
752            "regression: elapsed_ms must not call \
753             `current_timestamp()` — that's the wall-clock path \
754             with the saturating-on-jump-backward bug."
755        );
756    }
757}