orchestrator-sm: carry recovery attempt on RecoverComponent
Emit the per-component retry count as RecoverComponent { id, attempt } so
the driver picks a recovery source per attempt without its own counter.
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs
index 956689b..c86000b 100644
--- a/services/orchestrator/sm/src/lib.rs
+++ b/services/orchestrator/sm/src/lib.rs
@@ -3,13 +3,10 @@
//! `openprot_orchestrator_sm` — the eRoT boot-sequence state machine.
//!
-//! This is the pure-reducer core ported from `rot_reducer`. It describes side
-//! effects as [`Effect`] values rather than performing them; the surrounding
-//! OpenPRoT shell carries them out via a [`Platform`] impl. No concrete hardware
-//! appears here — the machine is generic over an opaque [`ComponentId`].
-//!
-//! See `docs/verification-model.md` and `docs/state-machine.md` in the
-//! `rot_reducer` workspace for the full domain context and design rationale.
+//! This is the pure decision core: it describes side effects as [`Effect`]
+//! values rather than performing them; the surrounding OpenPRoT shell carries
+//! them out via a [`Platform`] impl. No concrete hardware appears here — the
+//! machine is generic over an opaque [`ComponentId`].
//!
//! Three invariants define the boundary:
//! 1. **Effects flow through [`Sink`]** — fresh per event, drained afterward.
@@ -103,7 +100,7 @@
/// lockdown anyway, which is the louder signal.
pub fn emit(&mut self, effect: Effect) {
// Dead Err arm: overflow is proved impossible by `Rot::EFFECT_CAP_OK`
- // (`E >= 2 * N + 2`) plus the reducer never emitting more than
+ // (`E >= 2 * N + 2`) plus the state machine never emitting more than
// `2 * N + 2` effects into one Sink.
let _ = self.effects.push(effect);
}
@@ -473,7 +470,7 @@
}
}
-/// The reducer proper: the per-state handlers, the superstate handler, and the
+/// The state machine 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.
@@ -844,10 +841,20 @@
// while the platform restores it, so it is not quiesced again on
// the re-walk.
self.clear_awaiting_boot(failed);
- if let Some(i) = self.status_index(failed) {
- self.statuses[i].released = false;
- }
- ctx.emit(Effect::RecoverComponent(failed));
+ // `retry` here is the consecutive-recovery count for `failed`
+ // (0 on the first attempt); hand it to the driver so it need not
+ // track its own. It is bumped later, on `Restored`.
+ let attempt = match self.status_index(failed) {
+ Some(i) => {
+ self.statuses[i].released = false;
+ self.statuses[i].retry
+ }
+ None => 0,
+ };
+ ctx.emit(Effect::RecoverComponent {
+ id: failed,
+ attempt,
+ });
}
State::Locked => {
ctx.emit(Effect::LatchLockdown);
@@ -885,19 +892,19 @@
/// [`EffectError`] if it could not be performed. Never called with
/// [`Effect::Emit`] — the orchestrator consumes those internally.
///
-/// Contract the reducer relies on:
-/// - **Honest, complete feedback.** The reducer's correctness rests entirely on
+/// Contract the state machine relies on:
+/// - **Honest, complete feedback.** The core's correctness rests entirely on
/// the event stream the shell feeds back; dropping, reordering, or
/// synthesizing events silently breaks the state machine's invariants.
/// - **`AssertReset` holds, it does not pulse.** A reset must keep the component
-/// quiesced and non-executing until its matching `ReleaseReset`. The reducer's
+/// quiesced and non-executing until its matching `ReleaseReset`. The core's
/// at-rest verification guarantee depends on this: it re-asserts reset on
/// every live component before a recovery re-walk (`quiesce_all`) so that
/// `VerifyFirmware` covers code that cannot run or rewrite its own flash
/// between the check and the release. A reset that merely pulses would let a
/// component resume before verification and void that guarantee.
/// - **A failed [`Effect::LatchLockdown`] is a hard fault.** Lockdown is the top
-/// of the escalation ladder — the reducer has nothing stronger to emit and
+/// of the escalation ladder — the core has nothing stronger to emit and
/// will *believe* it is `Locked`. The shell must treat that failure as
/// terminal (halt/reset), not a recoverable error.
pub trait Platform {
@@ -1011,7 +1018,7 @@
for &effect in buf.effects() {
match effect {
Effect::Emit(internal) => {
- // Dead Err arm: the reducer emits at most one `Emit`
+ // Dead Err arm: the state machine emits at most one `Emit`
// (`RecoveryFailed`) per settle, well within PENDING_CAP.
let _ = pending.push(internal);
}
diff --git a/services/orchestrator/sm/src/model.rs b/services/orchestrator/sm/src/model.rs
index fff7cb6..b4f5074 100644
--- a/services/orchestrator/sm/src/model.rs
+++ b/services/orchestrator/sm/src/model.rs
@@ -3,7 +3,7 @@
//! Domain vocabulary for the orchestrator state machine: the component model,
//! events, effects, states, and the validated [`Chain`] of trust. These types
-//! carry no reducer behavior; the state machine itself lives in the crate root.
+//! carry no behavior; the state machine itself lives in the crate root.
/// An opaque identifier for one platform component. The core never inspects it;
/// the board layer decides which real hardware each id refers to.
@@ -304,9 +304,20 @@
/// Recover `id` from its configured recovery source. The mechanism —
/// golden image, A/B slot, streamed image, or vendor-specific scheme — is
/// deferred to the [`Platform`](crate::Platform) driver, which resolves it
- /// per configuration policy. The reducer only names the component to
+ /// per configuration policy. The core only names the component to
/// recover; it does not encode how recovery is performed.
- RecoverComponent(ComponentId),
+ ///
+ /// `attempt` is this component's consecutive-recovery count (0 on the first
+ /// attempt), taken straight from the core's own retry counter — the same
+ /// value the retry cap is measured against. It rides on the effect so the
+ /// driver can try a different recovery source each time (say, slot A on
+ /// attempt 0, slot B on 1, golden on 2) without counting attempts itself —
+ /// a count of its own could drift from the core's, since the driver never
+ /// sees when a recovery succeeds.
+ RecoverComponent {
+ id: ComponentId,
+ attempt: u8,
+ },
/// Report that a component has been isolated (held in reset and removed
/// from the trust chain) so management software is aware the platform is
/// running degraded. Emitted once per component, at the moment it is
@@ -381,7 +392,7 @@
///
/// Build one with [`TryFrom`]/[`TryInto`] from a `heapless::Vec` of
/// `(ComponentId, ComponentAttrs)` pairs. The conversion is the single place
-/// the reducer's structural invariants are enforced, so a malformed chain
+/// the state machine's structural invariants are enforced, so a malformed chain
/// fails closed at the boundary instead of misbehaving later:
///
/// - the chain is non-empty,
diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs
index df188ba..2b9a5af 100644
--- a/services/orchestrator/sm/src/tests.rs
+++ b/services/orchestrator/sm/src/tests.rs
@@ -317,7 +317,7 @@
],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// INV7 (feedback-as-data): after MAX_RETRY restores the core self-emits
@@ -543,7 +543,7 @@
&[BOOT, Event::VerificationPassed(C0), Event::Timeout(C0)],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// D2: a timeout for a component that is not awaiting boot-progress is
@@ -564,7 +564,7 @@
],
);
assert_eq!(state, State::AwaitingReady(Some(C0)));
- assert!(!effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
}
/// An out-of-chain id in a `VerificationFailed` report is dropped: the core
@@ -576,7 +576,7 @@
passive_required(&[C0, C1]),
&[BOOT, Event::VerificationFailed(C3)],
);
- assert!(!effects.contains(&Effect::RecoverComponent(C3)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C3, attempt: 0 }));
// Untouched: still walking the chain from the top with C0 under verification.
assert_eq!(state, State::PreSupervision);
}
@@ -595,7 +595,7 @@
Event::CorruptionDetected(C3),
],
);
- assert!(!effects.contains(&Effect::RecoverComponent(C3)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C3, attempt: 0 }));
assert_eq!(state, State::Ready);
}
@@ -614,7 +614,7 @@
);
assert_eq!(state, State::Recovering(C0));
assert!(effects.contains(&Effect::ReleaseReset(C0)));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// A passive boot timeout is caught even after the chain walk has completed and
@@ -630,7 +630,7 @@
&[BOOT, Event::VerificationPassed(C0), Event::Timeout(C0)],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// A passive component that reports [`Event::Booted`] retires its watchdog, so a
@@ -649,7 +649,7 @@
],
);
assert_eq!(state, State::Ready);
- assert!(!effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// D2: full path — timeout drives recovery, restore rewalks from the top, and
@@ -672,7 +672,7 @@
],
);
assert_eq!(state, State::Ready);
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
assert!(effects.contains(&Effect::ReleaseReset(C1)));
}
@@ -698,7 +698,7 @@
// C1 must never be released.
assert!(!effects.contains(&Effect::ReleaseReset(C1)));
// Recovery IS attempted before C1 is classified and held.
- assert!(effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(effects.contains(&Effect::AssertReset(C1)));
assert!(!effects.contains(&Effect::LatchLockdown));
}
@@ -724,7 +724,7 @@
);
assert_eq!(state, State::Ready);
assert!(!effects.contains(&Effect::ReleaseReset(C1)));
- assert!(effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(effects.contains(&Effect::AssertReset(C1)));
}
@@ -746,7 +746,7 @@
);
assert_eq!(state, State::Ready);
assert!(effects.contains(&Effect::AssertReset(C1)));
- assert!(!effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(!effects.contains(&Effect::LatchLockdown));
}
@@ -854,7 +854,7 @@
],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// Runtime corruption of a `Cascading` component must gate the whole
@@ -883,7 +883,7 @@
assert!(effects.contains(&Effect::AssertReset(C1)));
assert!(effects.contains(&Effect::AssertReset(C2)));
// No recovery is started for a non-required corruption.
- assert!(!effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(!effects.contains(&Effect::LatchLockdown));
}
@@ -975,7 +975,7 @@
assert!(effects.contains(&Effect::ReportIsolated(C2)));
assert!(effects.contains(&Effect::ReportIsolated(C3)));
// A non-required cascade never enters recovery or lockdown.
- assert!(!effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(!effects.contains(&Effect::LatchLockdown));
}
@@ -998,7 +998,7 @@
);
assert_eq!(state, State::Ready);
assert!(effects.contains(&Effect::ReportIsolated(C1)));
- assert!(!effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
}
/// A `Required` component whose recovery is exhausted is named in a
@@ -1046,7 +1046,7 @@
],
);
assert_eq!(state, State::Ready);
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
assert!(
!effects.iter().any(|e| matches!(
e,
@@ -1101,7 +1101,7 @@
&[BOOT, Event::VerificationFailed(C0)],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
// Component must never be released when its eRoT check failed.
assert!(!effects.contains(&Effect::ReleaseReset(C0)));
}
@@ -1120,7 +1120,7 @@
],
);
assert_eq!(state, State::Ready);
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
// ReleaseReset only after the recovery re-walk passes.
assert!(effects.contains(&Effect::ReleaseReset(C0)));
}
@@ -1141,7 +1141,7 @@
],
);
assert_eq!(state, State::Recovering(C1));
- assert!(effects.contains(&Effect::RecoverComponent(C1)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
assert!(!effects.contains(&Effect::ReleaseReset(C1)));
}
@@ -1161,7 +1161,7 @@
],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// CorruptionDetected while in Updating (required component) → Recovering
@@ -1178,7 +1178,7 @@
],
);
assert_eq!(state, State::Recovering(C0));
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// Concurrent faults: corruption of a *different* component arriving while the
@@ -1202,8 +1202,8 @@
);
assert_eq!(state, State::Recovering(C2));
// Both recovery episodes kicked off a recovery.
- assert!(effects.contains(&Effect::RecoverComponent(C1)));
- assert!(effects.contains(&Effect::RecoverComponent(C2)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C1, attempt: 0 }));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C2, attempt: 0 }));
assert!(!effects.contains(&Effect::LatchLockdown));
}
@@ -1291,7 +1291,7 @@
assert_eq!(state, State::Ready);
assert!(effects.contains(&Effect::ActivateUpdate));
assert!(!effects.contains(&Effect::DiscardStaged));
- assert!(!effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(!effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
}
/// The anti-rollback floor is committed only on a proven-healthy boot, never
@@ -1475,7 +1475,7 @@
assert!(!effects.contains(&Effect::ReleaseReset(C0)));
assert!(effects.contains(&Effect::ReleaseReset(C1)));
// Recovery IS attempted before C0 is classified and held.
- assert!(effects.contains(&Effect::RecoverComponent(C0)));
+ assert!(effects.contains(&Effect::RecoverComponent { id: C0, attempt: 0 }));
assert!(effects.contains(&Effect::AssertReset(C0)));
}
@@ -1549,7 +1549,7 @@
assert_eq!(Chain::try_from(empty).unwrap_err(), ChainError::Empty);
}
-/// A repeated `ComponentId` is rejected: the reducer's linear id lookups would
+/// A repeated `ComponentId` is rejected: the state machine's linear id lookups would
/// otherwise be ambiguous.
#[test]
fn chain_rejects_duplicate_id() {
@@ -1691,7 +1691,7 @@
c.push((C0, ComponentAttrs::passive_required())).unwrap();
let mut orch =
Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), MAX_RETRY);
- let mut plat = FailOn::new(Effect::RecoverComponent(C0));
+ let mut plat = FailOn::new(Effect::RecoverComponent { id: C0, attempt: 0 });
orch.dispatch(&mut plat, BOOT);
orch.dispatch(&mut plat, Event::VerificationFailed(C0)); // → Recovering → RecoverComponent(C0) fails
@@ -1949,7 +1949,7 @@
for effect in trace {
match effect {
- Effect::AssertReset(id) | Effect::RecoverComponent(id) => {
+ Effect::AssertReset(id) | Effect::RecoverComponent { id, .. } => {
verified[id.get() as usize] = false;
}
Effect::VerifyFirmware(id) => {