orchestrator-sm: discard staged image on update preemption; id-check Restored A Required corruption that preempts an in-flight update now emits DiscardStaged so the staged image is not orphaned. Recovering ignores a Restored whose id is not the current recovery target, preventing a displaced episode from mis-crediting the wrong component. Adds two tests.
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs index 7226787..cecf4b4 100644 --- a/services/orchestrator/sm/src/lib.rs +++ b/services/orchestrator/sm/src/lib.rs
@@ -448,11 +448,29 @@ ctx.emit(Effect::DiscardStaged); Outcome::Transition(State::Ready) } + Event::CorruptionDetected(id) => { + let outcome = self.handle_corruption(*id, ctx); + // Only a *preemption* (transition out to recovery) orphans + // the staged image. An `Isolable`/`Cascading` corruption + // returns `Handled` and the update continues untouched, so + // its staged image must survive — do not discard then. + if matches!(outcome, Outcome::Transition(State::Recovering(_))) { + ctx.emit(Effect::DiscardStaged); + } + outcome + } _ => Outcome::Super, }, State::Recovering(failed) => match event { - Event::Restored(_) => { + Event::Restored(id) => { + // A `Restored` for a component other than the current + // recovery target (e.g. an episode displaced by a later + // corruption) must not be credited to this recovery. Drop + // it; the eventual re-walk re-verifies every component. + if *id != failed { + return Outcome::Handled; + } // Count this attempt against the specific component in // recovery, not a global budget (CSA: exhaustion is // per-device). The recovery target is the state's payload,
diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs index af0153f..cd79f08 100644 --- a/services/orchestrator/sm/src/tests.rs +++ b/services/orchestrator/sm/src/tests.rs
@@ -1306,3 +1306,49 @@ assert_eq!(orch.state(), State::Locked); assert!(plat.recorded.contains(&Effect::LatchLockdown)); } + +/// GAP 1 (red): a `Required` corruption of another device while an update is in +/// flight preempts the update but leaves the staged image dangling. Correct +/// behavior emits `DiscardStaged` when leaving `Updating` for recovery. +#[test] +fn corruption_during_update_discards_staged() { + let (effects, state) = drive( + passive_required(&[C0, C1]), + &[ + BOOT, + Event::VerificationPassed(C0), + Event::VerificationPassed(C1), // → Ready + Event::UpdateRequest, // → Updating (AuthenticateUpdate, StageUpdate) + Event::CorruptionDetected(C1), // Required corruption preempts the update + ], + ); + assert_eq!(state, State::Recovering(C1)); + assert!( + effects.contains(&Effect::DiscardStaged), + "leaving Updating for recovery must discard the staged image", + ); +} + +/// GAP 2 (red): a second `Required` corruption while already recovering clobbers +/// the single `Recovering` slot, and because `Restored` is id-blind a restore +/// for the *displaced* target is mis-credited to the new one. Correct behavior: +/// a `Restored` whose id is not the recovery target does not advance recovery. +#[test] +fn restored_for_wrong_component_does_not_advance_recovery() { + let (_effects, state) = drive( + passive_required(&[C0, C1]), + &[ + BOOT, + Event::VerificationPassed(C0), + Event::VerificationPassed(C1), // → Ready + Event::CorruptionDetected(C0), // → Recovering(C0) + Event::CorruptionDetected(C1), // clobbers → Recovering(C1) + Event::Restored(C0), // restore of the *displaced* target + ], + ); + assert_eq!( + state, + State::Recovering(C1), + "a Restored for a non-target component must not be credited to the current recovery", + ); +}