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) => {