orchestrator: Compose boot-walk supervision from the board's BootWatch capability ReleaseReset arms the component's walk, AssertReset stops it, and the new PlatformDriver::poll_boot_walks maps terminal verdicts to events: Complete becomes ComponentReady (Active) or Booted (Passive), Failed becomes Timeout regardless of cause — retry budgeting is the SM's. A finished walk stops being watched, so each verdict is delivered once; while everything waits, the poll carries the earliest walk deadline as the run loop's next wake-up. BootWatch gains arm(): a retry re-release starts a fresh walk, and since reset actuation has no clock, the attempt starts at the next poll's now_millis. The Board supplies one walk and one ComponentKind per component, from the same table as the SM's chain. The driver README still described take_event and NotImplemented, both gone; rewritten to the current execute/fail-closed shape. Signed-off-by: Christina Quast <christina.quast@9elements.com>
diff --git a/services/orchestrator/capabilities/src/boot_watch.rs b/services/orchestrator/capabilities/src/boot_watch.rs index de55354..66c703c 100644 --- a/services/orchestrator/capabilities/src/boot_watch.rs +++ b/services/orchestrator/capabilities/src/boot_watch.rs
@@ -12,6 +12,13 @@ /// its walks in an enum and matches, without touching anything below the /// seam. pub trait BootWatch { + /// Starts a fresh attempt: previous progress is discarded and the walk + /// judges from its first checkpoint again. The driver calls this on + /// every reset release, retries included. Takes no timestamp — reset + /// actuation has no clock; the attempt starts at the next + /// [`poll`](BootWatch::poll)'s `now_millis`. + fn arm(&mut self); + /// Judges the walk at `now_millis` (monotonic). Never sleeps — time is /// injected, so every decision is host-testable. fn poll(&mut self, now_millis: u64) -> WalkVerdict; @@ -86,6 +93,10 @@ } impl BootWatch for ScriptedWalk { + fn arm(&mut self) { + self.next = 0; + } + fn poll(&mut self, _now_millis: u64) -> WalkVerdict { let v = self.verdicts[self.next]; self.next += 1;
diff --git a/services/orchestrator/driver/README.md b/services/orchestrator/driver/README.md index 51e03b3..2cb47ce 100644 --- a/services/orchestrator/driver/README.md +++ b/services/orchestrator/driver/README.md
@@ -4,24 +4,33 @@ # orchestrator platform driver (`openprot_orchestrator_driver`) The effect-executing layer around the orchestrator state machine. `PlatformDriver` -implements the SM's `Platform` seam: one method per `Effect`, each +implements the SM's `Platform` seam: one executor per `Effect`, each documenting its obligation from the platform-boundary contract -([orchestrator-model.md §6](../../../docs/src/design/orchestrator/orchestrator-model.md)). Unimplemented executors return -`DriverError::NotImplemented`; the SM fail-closes on them. +([orchestrator-model.md §6](../../../docs/src/design/orchestrator/orchestrator-model.md)). +An effect whose capability is not composed yet returns `EffectError` from +`execute`; the SM fail-closes on it. Everything device-specific arrives through the seams in `board.rs` -(`ImageSource`, `Verifier`, bundled in `Board`); executor-produced events -return to the SM via `PlatformDriver::take_event`. The event loop dispatches an -outside event, then keeps dispatching what the executors produced until -`take_event` returns `None`: +(`ImageSource`, `Verifier`, `BootControl`, `BootWatch`, bundled in `Board`). +Synchronous results (the verification verdict) return through `execute` and +settle within the same dispatch run — there is no driver-side event queue. + +Boot-walk verdicts are the one asynchronous read. `ReleaseReset` arms the +component's walk; the run loop polls and dispatches until quiet, then sleeps +until the earliest walk deadline: ```rust orch.dispatch(&mut driver, event); -while let Some(ev) = driver.take_event() { - orch.dispatch(&mut driver, ev); +loop { + let poll = driver.poll_boot_walks(now_millis); + match poll.event { + Some(ev) => orch.dispatch(&mut driver, ev), + None => break, // poll.next_deadline_millis = next wake-up + } } ``` -Implemented executors: `read_firmware`, `verify_firmware`. Everything else -returns `NotImplemented` until its pillar lands (boot walk, recovery, -update path, attestation, reporting). +Implemented executors: `ReadFirmware`, `VerifyFirmware`, `ReleaseReset` +(arms the boot walk), `AssertReset` (stops it). Everything else fails closed +until its pillar lands (recovery, update path, attestation, reporting, +lockdown latch).
diff --git a/services/orchestrator/driver/src/board.rs b/services/orchestrator/driver/src/board.rs index d1c2508..78ca53d 100644 --- a/services/orchestrator/driver/src/board.rs +++ b/services/orchestrator/driver/src/board.rs
@@ -4,9 +4,9 @@ //! What the board supplies to the driver: traits and wiring data only. //! Boards (or test mocks) implement these. -use openprot_orchestrator_sm::ComponentId; +use openprot_orchestrator_sm::{ComponentId, ComponentKind}; -pub use orchestrator_capabilities::BootControl; +pub use orchestrator_capabilities::{BootControl, BootWatch}; /// Access to one component's active firmware image, however it is reached — /// interposed flash, a PLDM/MCTP transfer, a RAM copy in tests. @@ -99,7 +99,9 @@ type Verifier: Verifier; /// Reset actuation for the managed components. type BootControl: BootControl; - // Later seams: Evidence (checkpoint walk), Recovery, Staging. + /// Boot-checkpoint supervision for the managed components. + type BootWatch: BootWatch; + // Later seams: Recovery, Staging. } /// Everything the board supplies, built once at bring-up and handed to @@ -112,11 +114,14 @@ /// type Image = SpiFlashImage; // interposed flash, offsets from the slot layout /// type Verifier = ManifestVerifier; // signature + SVN via the crypto engine /// type BootControl = ExtrstGpio; // per-component reset line +/// type BootWatch = CheckpointWalk; // GPIO checkpoint walk over the boot window /// } /// let board = Board::<Ast1060Board, 2> { /// images: [bmc_image, cpld_image], /// verifier, /// boot_controls: [bmc_reset, cpld_reset], +/// boot_watches: [bmc_walk, cpld_walk], +/// component_kinds: [ComponentKind::Active, ComponentKind::Passive], /// }; /// ``` pub struct Board<B: BoardCapabilities, const N: usize> { @@ -128,5 +133,12 @@ /// `boot_controls[i]` actuates `ComponentId(i)`'s reset, same indexing /// as `images`. pub boot_controls: [B::BootControl; N], - // Later seams add fields, e.g. evidence: [B::Evidence; N]. + /// `boot_watches[i]` supervises `ComponentId(i)`'s boot walk, same + /// indexing as `images`. + pub boot_watches: [B::BootWatch; N], + /// `component_kinds[i]` classifies `ComponentId(i)`: a completed walk becomes + /// `ComponentReady` for `Active`, `Booted` for `Passive`. Comes from + /// the same board table as the SM's chain, so both sides agree. + pub component_kinds: [ComponentKind; N], + // Later seams add fields, e.g. recovery: [B::Recovery; N]. }
diff --git a/services/orchestrator/driver/src/driver.rs b/services/orchestrator/driver/src/driver.rs index 8eb16f8..20dd800 100644 --- a/services/orchestrator/driver/src/driver.rs +++ b/services/orchestrator/driver/src/driver.rs
@@ -4,10 +4,10 @@ //! The [`PlatformDriver`]: one executor method per [`Effect`] variant, routed from //! the SM through the [`Platform`] impl. -use openprot_orchestrator_sm::{ComponentId, Effect, EffectError, Event, Platform}; +use openprot_orchestrator_sm::{ComponentId, ComponentKind, Effect, EffectError, Event, Platform}; use crate::board::{Board, BoardCapabilities, ImageSource, Verdict, Verifier}; -use orchestrator_capabilities::BootControl; +use orchestrator_capabilities::{BootControl, BootWatch, WalkVerdict}; /// Why the driver could not carry out an effect. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -45,6 +45,11 @@ board: Board<B, N>, /// Component whose image is staged (source opened) for verification. staged: Option<ComponentId>, + /// `watching[i]`: `ComponentId(i)` is out of reset with a walk in + /// flight. Set on `ReleaseReset`, cleared on `AssertReset` and on a + /// terminal verdict. Only watched walks are polled, so a finished or + /// quiesced walk emits no stale event. + watching: [bool; N], } impl<B: BoardCapabilities, const N: usize> PlatformDriver<B, N> { @@ -52,6 +57,7 @@ Self { board, staged: None, + watching: [false; N], } } @@ -101,22 +107,95 @@ .ok_or(DriverError::UnknownComponent) } - /// Release `id` from reset. The boot-checkpoint walk that feeds back - /// `Event::ComponentReady(id)`/`Event::Booted(id)`/`Event::Timeout(id)` - /// belongs to the BootWatch seam, not yet composed. + /// Release `id` from reset and arm its boot walk; + /// [`poll_boot_walks`](Self::poll_boot_walks) feeds the verdict back + /// as `ComponentReady(id)`/`Booted(id)`/`Timeout(id)`. Arms on every + /// release: a retry re-release starts a fresh walk. pub fn release_reset(&mut self, id: ComponentId) -> Result<(), DriverError> { self.boot_control(id)? .release() - .map_err(|_| DriverError::BootControlFault) + .map_err(|_| DriverError::BootControlFault)?; + let idx = id.get() as usize; + // In bounds: boot_control(id) above already rejected unknown ids. + self.board.boot_watches[idx].arm(); + self.watching[idx] = true; + Ok(()) } /// Hold `id` in reset — a durable quiesce, not a pulse; at-rest - /// verification and the recovery re-walk depend on it. + /// verification and the recovery re-walk depend on it. Also stops the + /// boot walk: a held device produces no boot signal, so polling it + /// could only yield a stale `Timeout`. pub fn assert_reset(&mut self, id: ComponentId) -> Result<(), DriverError> { self.boot_control(id)? .hold_in_reset() - .map_err(|_| DriverError::BootControlFault) + .map_err(|_| DriverError::BootControlFault)?; + self.watching[id.get() as usize] = false; + Ok(()) } + + /// Polls every watched walk at `now_millis` and returns the first + /// terminal verdict as its event: [`WalkVerdict::Complete`] becomes + /// `ComponentReady(id)` (`Active`) or `Booted(id)` (`Passive`), + /// [`WalkVerdict::Failed`] becomes `Timeout(id)` regardless of cause — + /// retry budgeting is the SM's. The finished walk stops being watched; + /// each verdict is delivered once. + /// + /// Returns at the first event; drain by calling until + /// [`BootWalkPoll::event`] is `None`. Only that last poll carries a + /// complete [`next_deadline_millis`](BootWalkPoll::next_deadline_millis) + /// — the earliest deadline among the still-waiting walks. + pub fn poll_boot_walks(&mut self, now_millis: u64) -> BootWalkPoll { + let mut next_deadline_millis: Option<u64> = None; + for idx in 0..N { + if !self.watching[idx] { + continue; + } + let id = ComponentId::new(idx as u8); + match self.board.boot_watches[idx].poll(now_millis) { + WalkVerdict::Waiting { deadline_millis } => { + next_deadline_millis = Some(match next_deadline_millis { + Some(d) => d.min(deadline_millis), + None => deadline_millis, + }); + } + WalkVerdict::Complete => { + self.watching[idx] = false; + let event = match self.board.component_kinds[idx] { + ComponentKind::Active => Event::ComponentReady(id), + ComponentKind::Passive => Event::Booted(id), + }; + return BootWalkPoll { + event: Some(event), + next_deadline_millis, + }; + } + WalkVerdict::Failed { .. } => { + self.watching[idx] = false; + return BootWalkPoll { + event: Some(Event::Timeout(id)), + next_deadline_millis, + }; + } + } + } + BootWalkPoll { + event: None, + next_deadline_millis, + } + } +} + +/// One [`PlatformDriver::poll_boot_walks`] round. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct BootWalkPoll { + /// The first terminal verdict's event; `None` when every watched walk + /// is still waiting. + pub event: Option<Event>, + /// Earliest deadline among walks seen waiting this round. Complete only + /// when [`event`](Self::event) is `None`: an early return skips the + /// walks after the finished one. + pub next_deadline_millis: Option<u64>, } impl<B: BoardCapabilities, const N: usize> Platform for PlatformDriver<B, N> {
diff --git a/services/orchestrator/driver/src/lib.rs b/services/orchestrator/driver/src/lib.rs index 0d2456c..d251b46 100644 --- a/services/orchestrator/driver/src/lib.rs +++ b/services/orchestrator/driver/src/lib.rs
@@ -15,10 +15,15 @@ //! driver-side event queue. //! //! Everything device-specific arrives through the seams in [`board`]: -//! image access ([`ImageSource`]), image judgment ([`Verifier`]) and reset -//! actuation ([`orchestrator_capabilities::BootControl`]), bundled in one +//! image access ([`ImageSource`]), image judgment ([`Verifier`]), reset +//! actuation ([`orchestrator_capabilities::BootControl`]) and boot +//! supervision ([`orchestrator_capabilities::BootWatch`]), bundled in one //! [`Board`] built by the board's composition crate. //! +//! Boot-walk verdicts are the one asynchronous read: the run loop calls +//! [`PlatformDriver::poll_boot_walks`] and dispatches the returned events +//! (`ComponentReady`/`Booted`/`Timeout`) into the SM. +//! //! [`Platform`]: openprot_orchestrator_sm::Platform #![no_std] @@ -30,4 +35,4 @@ mod tests; pub use board::{Board, BoardCapabilities, ImageSource, Verdict, Verifier}; -pub use driver::{DriverError, PlatformDriver}; +pub use driver::{BootWalkPoll, DriverError, PlatformDriver};
diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs index 5790489..f98851a 100644 --- a/services/orchestrator/driver/src/tests.rs +++ b/services/orchestrator/driver/src/tests.rs
@@ -5,8 +5,9 @@ use crate::*; use openprot_orchestrator_sm::{ - ComponentAttrs, ComponentId, Event, Orchestrator, PowerOnResult, State, + ComponentAttrs, ComponentId, ComponentKind, Event, Orchestrator, PowerOnResult, State, }; +use orchestrator_capabilities::{BootWatch, FailureCause, WalkVerdict}; const C0: ComponentId = ComponentId::new(0); @@ -175,6 +176,54 @@ } } +/// Boot walk without a device; scripted verdicts. An exhausted script +/// holds its last verdict; an empty script waits forever. `arm` rewinds +/// to the script start, so a fresh attempt is observable from the +/// verdicts alone — no poll or arm counters needed. +struct MockWalk { + verdicts: std::vec::Vec<WalkVerdict>, + next: usize, +} + +const IDLE_DEADLINE: u64 = 60_000; + +impl MockWalk { + fn scripted(verdicts: std::vec::Vec<WalkVerdict>) -> Self { + Self { verdicts, next: 0 } + } + + /// A walk that reports "still waiting" forever. + fn idle() -> Self { + Self::scripted(std::vec::Vec::new()) + } +} + +impl BootWatch for MockWalk { + fn arm(&mut self) { + self.next = 0; + } + + fn poll(&mut self, _now_millis: u64) -> WalkVerdict { + match self.verdicts.get(self.next) { + Some(v) => { + self.next += 1; + *v + } + // Exhausted: repeat the last verdict, like a real finished + // walk. A driver bug that re-polls one then shows up as a + // duplicate event in the exactly-once assertions instead of + // panicking here. + None => self + .verdicts + .last() + .copied() + .unwrap_or(WalkVerdict::Waiting { + deadline_millis: IDLE_DEADLINE, + }), + } + } +} + /// The test board's type choices. struct MockBoard; @@ -182,6 +231,7 @@ type Image = MemImage; type Verifier = XorVerifier; type BootControl = MockReset; + type BootWatch = MockWalk; } fn driver(images: [MemImage; 1]) -> PlatformDriver<MockBoard, 1> { @@ -189,6 +239,8 @@ images, verifier: XorVerifier { fault: false }, boot_controls: [MockReset::new()], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }) } @@ -270,6 +322,8 @@ images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: true }, boot_controls: [MockReset::new()], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }); orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); @@ -288,6 +342,8 @@ ], verifier: XorVerifier { fault: false }, boot_controls: [MockReset::new(), MockReset::new()], + boot_watches: [MockWalk::idle(), MockWalk::idle()], + component_kinds: [ComponentKind::Passive, ComponentKind::Passive], }); driver.stage_firmware(C0).unwrap(); @@ -324,6 +380,8 @@ images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: false }, boot_controls: [control], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }); driver.release_reset(C0).unwrap(); @@ -355,6 +413,8 @@ images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: false }, boot_controls: [control], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }); assert_eq!(driver.release_reset(C0), Err(DriverError::BootControlFault)); @@ -416,6 +476,7 @@ type Image = MemImage; type Verifier = LineWatchingVerifier; type BootControl = MockReset; + type BootWatch = MockWalk; } // The at-rest guarantee end to end: the component is still held while its @@ -433,6 +494,8 @@ held_during_verify: held_during_verify.clone(), }, boot_controls: [control], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }); let mut orch = orchestrator(); @@ -457,6 +520,8 @@ images: [MemImage::holding(valid_image())], verifier: XorVerifier { fault: false }, boot_controls: [control], + boot_watches: [MockWalk::idle()], + component_kinds: [ComponentKind::Passive], }); let mut orch = orchestrator(); @@ -465,3 +530,245 @@ assert_eq!(orch.state(), State::Locked); assert!(held.get(), "never left reset"); } + +// --------------------------------------------------------------------------- +// Boot-walk supervision. +// --------------------------------------------------------------------------- + +/// A 2-component driver with per-component scripted walks and kinds; +/// everything else is the happy-path mock. +fn walk_driver( + walks: [MockWalk; 2], + component_kinds: [ComponentKind; 2], +) -> PlatformDriver<MockBoard, 2> { + PlatformDriver::new(Board { + images: [ + MemImage::holding(valid_image()), + MemImage::holding(valid_image()), + ], + verifier: XorVerifier { fault: false }, + boot_controls: [MockReset::new(), MockReset::new()], + boot_watches: walks, + component_kinds, + }) +} + +// A completed walk becomes ComponentReady for Active, Booted for Passive. +// One event per call, drained in index order; a finished walk never +// reports twice. +#[test] +fn completed_walks_report_by_kind() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![WalkVerdict::Complete]), + MockWalk::scripted(std::vec![WalkVerdict::Complete]), + ], + [ComponentKind::Active, ComponentKind::Passive], + ); + driver.release_reset(C0).unwrap(); + driver.release_reset(C1).unwrap(); + + assert_eq!( + driver.poll_boot_walks(0).event, + Some(Event::ComponentReady(C0)) + ); + assert_eq!(driver.poll_boot_walks(0).event, Some(Event::Booted(C1))); + + let quiet = driver.poll_boot_walks(0); + assert_eq!(quiet.event, None, "verdicts are delivered exactly once"); + assert_eq!(quiet.next_deadline_millis, None, "no walk left waiting"); +} + +// A failed walk becomes Timeout(id) regardless of cause — the retry +// decision is the SM's. +// TODO: the SM only knows Timeout, so DeviceFatal still spends retry +// budget. Add a fatal, unrecoverable-error event to the SM in a later PR. +#[test] +fn failed_walks_map_to_timeout() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![WalkVerdict::Failed { + checkpoint: "heartbeat", + cause: FailureCause::TimedOut, + }]), + MockWalk::scripted(std::vec![WalkVerdict::Failed { + checkpoint: "self-test", + cause: FailureCause::DeviceFatal, + }]), + ], + [ComponentKind::Active, ComponentKind::Passive], + ); + driver.release_reset(C0).unwrap(); + driver.release_reset(C1).unwrap(); + + assert_eq!(driver.poll_boot_walks(0).event, Some(Event::Timeout(C0))); + assert_eq!(driver.poll_boot_walks(0).event, Some(Event::Timeout(C1))); + assert_eq!(driver.poll_boot_walks(0).event, None); +} + +// An event-carrying poll returns before visiting later walks, so its +// deadline is partial and must not be trusted; the drain's final, +// event-free poll visits every remaining walk and reports the earliest +// deadline. +#[test] +fn deadline_is_authoritative_only_when_no_event() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![WalkVerdict::Complete]), + MockWalk::scripted(std::vec![WalkVerdict::Waiting { + deadline_millis: 1_000, + }]), + ], + [ComponentKind::Passive, ComponentKind::Passive], + ); + driver.release_reset(C0).unwrap(); + driver.release_reset(C1).unwrap(); + + let first = driver.poll_boot_walks(0); + assert_eq!(first.event, Some(Event::Booted(C0))); + assert_eq!( + first.next_deadline_millis, None, + "returned before the waiting walk was visited" + ); + + let last = driver.poll_boot_walks(0); + assert_eq!(last.event, None); + assert_eq!(last.next_deadline_millis, Some(1_000)); +} + +// While every watched walk waits, the poll carries the earliest deadline +// as the run loop's next wake-up. +#[test] +fn waiting_walks_report_the_earliest_deadline() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![WalkVerdict::Waiting { + deadline_millis: 9_000, + }]), + MockWalk::scripted(std::vec![WalkVerdict::Waiting { + deadline_millis: 4_000, + }]), + ], + [ComponentKind::Passive, ComponentKind::Passive], + ); + driver.release_reset(C0).unwrap(); + driver.release_reset(C1).unwrap(); + + let poll = driver.poll_boot_walks(0); + assert_eq!(poll.event, None); + assert_eq!(poll.next_deadline_millis, Some(4_000)); +} + +// A walk is watched only between release and terminal verdict. The script's +// terminal verdict would surface as an event if the gate were missing: no +// event before release, no stale event after assert_reset, and the verdict +// still arrives once the device is actually released. +#[test] +fn only_released_components_are_watched() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![WalkVerdict::Complete]), + MockWalk::idle(), + ], + [ComponentKind::Passive, ComponentKind::Passive], + ); + + assert_eq!( + driver.poll_boot_walks(0).event, + None, + "unreleased: no event" + ); + + driver.release_reset(C0).unwrap(); + driver.assert_reset(C0).unwrap(); + assert_eq!( + driver.poll_boot_walks(0).event, + None, + "back in reset: no stale event" + ); + + driver.release_reset(C0).unwrap(); + assert_eq!(driver.poll_boot_walks(0).event, Some(Event::Booted(C0))); +} + +// Every release re-arms the walk: a retry judges a new attempt from the +// first checkpoint, not the failed one resumed. With a script of +// [Failed, Complete], a resumed walk would report Complete on the second +// attempt; a fresh one reports Failed again. +#[test] +fn rerelease_arms_a_fresh_walk() { + let mut driver = walk_driver( + [ + MockWalk::scripted(std::vec![ + WalkVerdict::Failed { + checkpoint: "heartbeat", + cause: FailureCause::TimedOut, + }, + WalkVerdict::Complete, + ]), + MockWalk::idle(), + ], + [ComponentKind::Passive, ComponentKind::Passive], + ); + + driver.release_reset(C0).unwrap(); + assert_eq!(driver.poll_boot_walks(0).event, Some(Event::Timeout(C0))); + + driver.release_reset(C0).unwrap(); + assert_eq!( + driver.poll_boot_walks(0).event, + Some(Event::Timeout(C0)), + "fresh attempt from the first checkpoint, not the old walk resumed" + ); +} + +// End to end: a passive component is released speculatively (Ready), its +// walk completes, and the Booted event settles cleanly. +#[test] +fn booted_walk_settles_in_ready() { + let mut orch = orchestrator(); + let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { + images: [MemImage::holding(valid_image())], + verifier: XorVerifier { fault: false }, + boot_controls: [MockReset::new()], + boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Complete])], + component_kinds: [ComponentKind::Passive], + }); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + assert_eq!(orch.state(), State::Ready); + + let event = driver.poll_boot_walks(0).event.expect("walk completed"); + assert_eq!(event, Event::Booted(C0)); + orch.dispatch(&mut driver, event); + + assert_eq!(orch.state(), State::Ready); +} + +// End to end, failure path: the released component never reports in, its +// Timeout enters recovery, and with no recovery capability composed yet +// the machine fails closed. The Recovery PR replaces this test with the +// recovery-path one — its failure there is the reminder. +#[test] +fn boot_timeout_fails_closed_without_recovery() { + let mut orch = orchestrator(); + let mut driver = PlatformDriver::<MockBoard, 1>::new(Board { + images: [MemImage::holding(valid_image())], + verifier: XorVerifier { fault: false }, + boot_controls: [MockReset::new()], + boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Failed { + checkpoint: "heartbeat", + cause: FailureCause::TimedOut, + }])], + component_kinds: [ComponentKind::Passive], + }); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + assert_eq!(orch.state(), State::Ready); + + let event = driver.poll_boot_walks(0).event.expect("walk failed"); + assert_eq!(event, Event::Timeout(C0)); + orch.dispatch(&mut driver, event); + + assert_eq!(orch.state(), State::Locked); +}