orchestrator-sm: replace statig with hand-written reducer Drops the statig 0.4.1 dependency, which was used in exactly one file and never leaked into the public API. The hierarchical state machine is now a hand-written reducer: per-state handlers, one supervising handler, and entry actions as plain inherent methods on Rot, driven by a small engine (Orchestrator::step) that reproduces statig's per-event semantics — dispatch to the leaf, fall through to the supervisor on Super, and run the target's entry action only on Transition. No observable behavior change; all 42 unit tests pass with tests.rs and model.rs unmodified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/services/orchestrator/sm/BUILD.bazel b/services/orchestrator/sm/BUILD.bazel index b9ea993..6707f9f 100644 --- a/services/orchestrator/sm/BUILD.bazel +++ b/services/orchestrator/sm/BUILD.bazel
@@ -15,7 +15,6 @@ visibility = ["//visibility:public"], deps = [ "@rust_crates//:heapless", - "@rust_crates//:statig", ], )
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs index 601e95c..4c52a9d 100644 --- a/services/orchestrator/sm/src/lib.rs +++ b/services/orchestrator/sm/src/lib.rs
@@ -23,12 +23,6 @@ use core::marker::PhantomData; -use statig::Outcome; -use statig::blocking::{ - IntoStateMachine, IntoStateMachineExt as _, State as StatigState, StateMachine, - Superstate as StatigSuperstate, -}; - mod model; pub use model::*; @@ -53,39 +47,17 @@ "PENDING_CAP must hold one outside event + one Emit follow-up + one EffectFailed", ); -/// Superstate entered once the eRoT exits [`State::PreSupervision`] — i.e. on -/// release of the first `Active` component, or once the whole chain has -/// finished if it is all-`Passive`. Provides two platform-wide guarantees -/// that must hold across all four sub-states ([`State::AwaitingReady`], -/// [`State::Ready`], [`State::Updating`], [`State::Recovering`]): -/// -/// - Attestation challenges are always answered. -/// - Corruption of a required component always triggers recovery. -/// -/// Note: [`State::PreSupervision`] itself is *not* linked to this superstate -/// (its `superstate()` returns `None`), so the attestation guarantee does not -/// hold while a component is still being walked there — CSA defines no -/// requirement to answer challenges before the chain walk completes, so -/// `AttestationChallenge` is left unhandled in `PreSupervision` (discarded via -/// `Outcome::Super`). -/// -/// The corruption guarantee, however, *does* hold in `PreSupervision`: -/// [`State::PreSupervision`] handles [`Event::CorruptionDetected`] directly -/// (via [`Rot::handle_corruption`]) rather than through this superstate, -/// since linking the whole superstate in would also pull in the attestation -/// behavior above. CSA defines no mechanism guaranteeing a corruption report -/// arrives for an already-released component's *live, executing* state (its -/// only at-rest mechanism — background NVM integrity polling — is explicitly -/// scoped to "at rest"/"between boots", not an in-progress boot's chain -/// walk), but that only means such a report isn't guaranteed to exist — it's -/// not a reason to discard one if it does arrive. See -/// `corruption_during_presupervision_selfloop_triggers_recovery` below. -#[derive(Debug)] -pub enum Superstate<'sub> { - SupervisingPlatform(PhantomData<&'sub ()>), +/// Result of dispatching one event to a state (or its superstate). +enum Outcome { + /// Event consumed; state unchanged; no entry action runs. + Handled, + /// Change to this state and run its entry action. + Transition(State), + /// Not handled here; defer to the superstate (or discard if none). + Super, } -/// The effect buffer handed to every handler (statig's `Context`), sized to `E`. +/// The effect buffer handed to every handler, sized to `E`. /// /// The only thing a handler can do to the outside world is call `emit`. The /// orchestrator gives each event a fresh `Sink` and drains it afterward. @@ -296,7 +268,7 @@ /// put, so a later re-walk skips it instead of silently re-releasing one we /// already found corrupt; `Required`/unknown → recover first (the /// halt-on-exhaustion decision happens later in `Recovering`). - fn handle_corruption(&mut self, id: ComponentId, ctx: &mut Sink<E>) -> Outcome<State> { + fn handle_corruption(&mut self, id: ComponentId, ctx: &mut Sink<E>) -> Outcome { match self.gate_by_policy(ctx, id) { Gating::Gated => Outcome::Handled, Gating::NotGated => { @@ -331,25 +303,18 @@ } } -impl<const N: usize, const E: usize> IntoStateMachine for Rot<N, E> { - type Event<'evt> = Event; - type Context<'ctx> = Sink<E>; - type State = State; - type Superstate<'sub> = Superstate<'sub>; - - fn initial() -> State { - State::PowerOnReset - } -} - -impl<const N: usize, const E: usize> StatigState<Rot<N, E>> for State { - fn call_handler( - &mut self, - rot: &mut Rot<N, E>, - event: &Event, - ctx: &mut Sink<E>, - ) -> Outcome<State> { - match self { +/// The reducer proper: the per-state handlers, the superstate handler, and the +/// entry actions. These are pure functions of `(stored data, state, event)` — +/// they mutate [`Rot`]'s storage and push [`Effect`]s into the [`Sink`], and +/// return an [`Outcome`] describing what should happen to the current state. +/// Applying that outcome is the engine's job ([`Orchestrator::step`]). +impl<const N: usize, const E: usize> Rot<N, E> { + /// Dispatch `event` to the handler for `state` (the leaf state). An + /// [`Outcome::Super`] here means "not handled at this level" — the engine + /// forwards to [`handle_supervising`](Self::handle_supervising) if `state` + /// is supervised, and otherwise discards the event. + fn handle(&mut self, state: State, event: &Event, ctx: &mut Sink<E>) -> Outcome { + match state { State::PowerOnReset => match event { Event::PowerGood(PowerOnResult::Provisioned) => { Outcome::Transition(State::PreSupervision) @@ -369,14 +334,14 @@ Event::VerificationPassed(id) => { // The component passed its check — it has recovered, so its // consecutive-failure streak ends (INV7: consecutive only). - rot.clear_retry(*id); + self.clear_retry(*id); ctx.emit(Effect::ReleaseReset(*id)); - let current_kind = rot.chain.get(rot.cursor as usize).map(|(_, a)| a.kind); - let next_idx = (rot.cursor as usize).saturating_add(1); - if rot.advance_to_next_ungated(ctx, next_idx) { + let current_kind = self.chain.get(self.cursor as usize).map(|(_, a)| a.kind); + let next_idx = (self.cursor as usize).saturating_add(1); + if self.advance_to_next_ungated(ctx, next_idx) { match current_kind { Some(ComponentKind::Active) => { - rot.awaiting = Some(*id); + self.awaiting = Some(*id); Outcome::Transition(State::AwaitingReady) } _ => Outcome::Handled, @@ -389,7 +354,7 @@ // Recovery is attempted first for every failure, regardless // of the component's recovery-failure policy (CSA: recover // first, classify only once retries are exhausted). - rot.failed = Some(*id); + self.failed = Some(*id); Outcome::Transition(State::Recovering) } // Minimal fix: react to a corruption report if one arrives, @@ -400,21 +365,21 @@ // exists in the first place. `AttestationChallenge` is left // unhandled here (falls through to `Outcome::Super` and is // discarded) — that's a separate question. - Event::CorruptionDetected(id) => rot.handle_corruption(*id, ctx), + Event::CorruptionDetected(id) => self.handle_corruption(*id, ctx), Event::EffectFailed => Outcome::Transition(State::Locked), _ => Outcome::Super, }, State::AwaitingReady => match event { Event::ComponentReady(id) => { - if rot.awaiting != Some(*id) { + if self.awaiting != Some(*id) { return Outcome::Handled; // spurious / stale (INV9) } - rot.awaiting = None; + self.awaiting = None; // If cursor is past the end, the eRoT side of the walk has // already finished (chain done, or the remainder is held) — // nothing left to verify, we're done. - if (rot.cursor as usize) >= rot.chain.len() { + if (self.cursor as usize) >= self.chain.len() { Outcome::Transition(State::Ready) } else { Outcome::Handled @@ -423,10 +388,10 @@ Event::VerificationPassed(id) => { // The component passed its check — it has recovered, so its // consecutive-failure streak ends (INV7: consecutive only). - rot.clear_retry(*id); + self.clear_retry(*id); ctx.emit(Effect::ReleaseReset(*id)); - let next_idx = (rot.cursor as usize).saturating_add(1); - if rot.advance_to_next_ungated(ctx, next_idx) { + let next_idx = (self.cursor as usize).saturating_add(1); + if self.advance_to_next_ungated(ctx, next_idx) { Outcome::Handled } else { Outcome::Transition(State::Ready) @@ -435,8 +400,8 @@ Event::VerificationFailed(id) => { // Recovery is attempted first for every failure, regardless // of the component's recovery-failure policy. - rot.failed = Some(*id); - rot.awaiting = None; + self.failed = Some(*id); + self.awaiting = None; Outcome::Transition(State::Recovering) } _ => Outcome::Super, @@ -465,21 +430,21 @@ // recovery, not a global budget (CSA: exhaustion is // per-device). `failed` is always `Some` while in // `Recovering`; treat a missing id as exhausted defensively. - let attempts = rot + let attempts = self .failed - .map(|id| rot.bump_retry(id)) - .unwrap_or(rot.max_retry); - if attempts < rot.max_retry { + .map(|id| self.bump_retry(id)) + .unwrap_or(self.max_retry); + if attempts < self.max_retry { Outcome::Transition(State::PreSupervision) } else { // Retries exhausted: gate via the same `gate_by_policy` // the runtime-corruption path uses, so the two can never // disagree. Gated → continue the walk; NotGated // (Required/unknown) → lock down. - match rot.failed.map(|id| (id, rot.gate_by_policy(ctx, id))) { + match self.failed.map(|id| (id, self.gate_by_policy(ctx, id))) { Some((id, Gating::Gated)) => { - rot.clear_retry(id); - rot.failed = None; + self.clear_retry(id); + self.failed = None; Outcome::Transition(State::PreSupervision) } // `Required`, or an unknown/missing id: lock down. @@ -498,18 +463,62 @@ } } - fn call_entry_action(&mut self, rot: &mut Rot<N, E>, ctx: &mut Sink<E>) { - match self { + /// The supervising handler, reached only when [`handle`](Self::handle) + /// returns [`Outcome::Super`] from one of the four supervised states + /// ([`State::AwaitingReady`], [`State::Ready`], [`State::Updating`], + /// [`State::Recovering`] — see [`Orchestrator::is_supervised`]). It provides + /// two platform-wide guarantees that must hold across all four: + /// + /// - Attestation challenges are always answered. + /// - Corruption of a required component always triggers recovery. + /// + /// Note: [`State::PreSupervision`] is deliberately *not* supervised, so the + /// attestation guarantee does not hold while a component is still being + /// walked there — CSA defines no requirement to answer challenges before the + /// chain walk completes, so `AttestationChallenge` is left unhandled in + /// `PreSupervision` (falls through to [`Outcome::Super`] and, with no + /// supervisor, is discarded). + /// + /// The corruption guarantee, however, *does* hold in `PreSupervision`: that + /// state handles [`Event::CorruptionDetected`] directly (via + /// [`handle_corruption`](Self::handle_corruption)) rather than through this + /// handler, since routing it here would also pull in the attestation + /// behavior above. CSA defines no mechanism guaranteeing a corruption report + /// arrives for an already-released component's *live, executing* state (its + /// only at-rest mechanism — background NVM integrity polling — is explicitly + /// scoped to "at rest"/"between boots", not an in-progress boot's chain + /// walk), but that only means such a report isn't guaranteed to exist — it's + /// not a reason to discard one if it does arrive. See + /// `corruption_during_presupervision_selfloop_triggers_recovery` in + /// `tests.rs`. + fn handle_supervising(&mut self, event: &Event, ctx: &mut Sink<E>) -> Outcome { + match event { + Event::AttestationChallenge => { + ctx.emit(Effect::SignAttestation); + Outcome::Handled + } + Event::CorruptionDetected(id) => self.handle_corruption(*id, ctx), + Event::EffectFailed => Outcome::Transition(State::Locked), + _ => Outcome::Super, + } + } + + /// Run `state`'s entry action. Called by the engine only when a handler + /// returned [`Outcome::Transition`] — never on [`Outcome::Handled`]. That + /// distinction is load-bearing: `PreSupervision`'s cursor walk returns + /// `Handled` precisely so this does not re-run and reset the cursor. + fn entry_action(&mut self, state: State, ctx: &mut Sink<E>) { + match state { State::PreSupervision => { - rot.awaiting = None; - let _ = rot.advance_to_next_ungated(ctx, 0); + self.awaiting = None; + let _ = self.advance_to_next_ungated(ctx, 0); } State::Updating => { ctx.emit(Effect::AuthenticateUpdate); ctx.emit(Effect::StageUpdate); } State::Recovering => { - if let Some(failed) = rot.failed { + if let Some(failed) = self.failed { ctx.emit(Effect::RestoreGoldenImage(failed)); } } @@ -526,42 +535,12 @@ // Retry counts, by contrast, ARE cleared: a clean boot means no // recovery episode is in flight, so every per-component streak // resets to zero. - rot.retries.clear(); - rot.failed = None; + self.retries.clear(); + self.failed = None; } _ => {} } } - - fn superstate(&mut self) -> Option<Superstate<'_>> { - match self { - State::Ready | State::Updating | State::Recovering | State::AwaitingReady => { - Some(Superstate::SupervisingPlatform(PhantomData)) - } - _ => None, - } - } -} - -impl<const N: usize, const E: usize> StatigSuperstate<Rot<N, E>> for Superstate<'_> { - fn call_handler( - &mut self, - rot: &mut Rot<N, E>, - event: &Event, - ctx: &mut Sink<E>, - ) -> Outcome<State> { - match self { - Superstate::SupervisingPlatform(_) => match event { - Event::AttestationChallenge => { - ctx.emit(Effect::SignAttestation); - Outcome::Handled - } - Event::CorruptionDetected(id) => rot.handle_corruption(*id, ctx), - Event::EffectFailed => Outcome::Transition(State::Locked), - _ => Outcome::Super, - }, - } - } } /// Signals that the shell could not carry out an [`Effect`]. The machine does @@ -590,21 +569,70 @@ fn execute(&mut self, effect: Effect) -> Result<(), EffectError>; } -/// A handle for a caller's own event loop. Wraps the statig machine so callers -/// only depend on this crate, never on statig types directly. +/// A handle for a caller's own event loop. Owns the machine's storage +/// ([`Rot`]) and its current [`State`], and drives them through [`step`]. +/// +/// [`step`]: Self::step pub struct Orchestrator<const N: usize, const E: usize> { - machine: StateMachine<Rot<N, E>>, + rot: Rot<N, E>, + state: State, } impl<const N: usize, const E: usize> Orchestrator<N, E> { pub fn new(chain: Chain<N>, max_retry: u8) -> Self { Self { - machine: Rot::new(chain, max_retry).state_machine(), + rot: Rot::new(chain, max_retry), + // The initial state. `PowerOnReset` has no entry action, so there is + // nothing to run here before the first event. + state: State::PowerOnReset, } } pub fn state(&self) -> State { - *self.machine.state() + self.state + } + + /// Whether `state` is nested under the supervising handler + /// ([`Rot::handle_supervising`]) — i.e. whether an [`Outcome::Super`] from + /// its leaf handler has anywhere to go. The four supervised states are the + /// ones the eRoT can be in after it has exited [`State::PreSupervision`], + /// and before it locks down. + const fn is_supervised(state: State) -> bool { + matches!( + state, + State::AwaitingReady | State::Ready | State::Updating | State::Recovering + ) + } + + /// Reduce one event: dispatch, fall through to the supervisor if needed, and + /// apply the resulting outcome. + /// + /// The machine defines no exit actions and no supervisor entry/exit actions, + /// so a transition's only side effect is the target state's entry action — + /// and it lands in the *same* `ctx` as the handler that caused it, which the + /// `E >= N + 2` bound relies on (a full gate cascade plus the destination + /// `PreSupervision` entry's two effects share one `Sink`). + fn step(&mut self, event: &Event, ctx: &mut Sink<E>) { + // 1. Dispatch to the current (leaf) state. + let mut outcome = self.rot.handle(self.state, event, ctx); + + // 2. On `Super`, defer to the supervising handler if this state has one; + // otherwise the event is discarded. Discarding is what keeps `Locked` + // terminal: it is unsupervised, so its blanket `Super` drops every + // event — including the `EffectFailed` from a failed `LatchLockdown`, + // which would otherwise loop. + if let Outcome::Super = outcome + && Self::is_supervised(self.state) + { + outcome = self.rot.handle_supervising(event, ctx); + } + + // 3. Apply a transition. `Handled` and an unhandled `Super` both leave + // the state alone and run no entry action. + if let Outcome::Transition(target) = outcome { + self.state = target; + self.rot.entry_action(target, ctx); + } } /// Handle one event all the way through — including any [`Effect::Emit`] @@ -630,7 +658,7 @@ i += 1; let mut buf = Sink::<E>::new(); - self.machine.handle_with_context(&ev, &mut buf); + self.step(&ev, &mut buf); for &effect in buf.effects() { match effect {
diff --git a/third_party/crates_io/Cargo.lock b/third_party/crates_io/Cargo.lock index f99ec31..d9b826d 100644 --- a/third_party/crates_io/Cargo.lock +++ b/third_party/crates_io/Cargo.lock
@@ -1339,7 +1339,6 @@ "sha3", "smlang", "spdm-lib", - "statig", "subtle", "syn 1.0.109", "syn 2.0.119", @@ -1579,12 +1578,6 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "statig" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c04b4a9f2d66294d63bdd8df834caad9f8e181997c3cf766b6b4f6d12d4fbc" - -[[package]] name = "string_morph" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/third_party/crates_io/Cargo.toml b/third_party/crates_io/Cargo.toml index 20f3b80..7833db3 100644 --- a/third_party/crates_io/Cargo.toml +++ b/third_party/crates_io/Cargo.toml
@@ -32,7 +32,6 @@ serde = { version = "1.0.219", default-features = false, features = ["derive"] } serde_derive = "1.0.228" smlang = { version = "0.8.0", default-features = false } -statig = { version = "0.4.1", default-features = false } syn = { version = "2.0.104", features = ["full", "extra-traits"] } syn1 = { package = "syn", version = "1.0.109", features = ["full", "extra-traits"] } tock-registers = "0.9.0"