orchestrator-sm: validated Chain newtype for construction

Introduce Chain<N> with a TryFrom that validates the chain of trust at the
boundary (non-empty, unique ids, depends_on exists and is strictly earlier,
length fits u8). Orchestrator/Rot::new take Chain<N> instead of a raw
heapless::Vec, so the storage representation is no longer part of the public
constructor. Add ChainError tests.
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs
index d527a80..d4338c7 100644
--- a/services/orchestrator/sm/src/lib.rs
+++ b/services/orchestrator/sm/src/lib.rs
@@ -23,11 +23,11 @@
 
 use core::marker::PhantomData;
 
+use statig::Outcome;
 use statig::blocking::{
     IntoStateMachine, IntoStateMachineExt as _, State as StatigState, StateMachine,
     Superstate as StatigSuperstate,
 };
-use statig::Outcome;
 
 // Internal capacities — these follow from how the machine works, not from the
 // deployment. The board owns `N` (chain length), `E` (effect-buffer size) and
@@ -348,6 +348,101 @@
     NotGated,
 }
 
+/// A validated **chain of trust**: the ordered list of components the eRoT
+/// walks, verifies, and supervises, in walk order.
+///
+/// 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
+/// fails closed at the boundary instead of misbehaving later:
+///
+/// - the chain is non-empty,
+/// - every [`ComponentId`] is unique,
+/// - every `depends_on` names a component that exists and appears *strictly
+///   earlier* in the chain (no dangling, forward, or self dependencies — a
+///   dependency is always walked before its dependents),
+/// - the length fits `u8`, the `cursor` index type.
+///
+/// ```ignore
+/// let mut v = heapless::Vec::<_, 4>::new();
+/// v.push((ComponentId::new(0), ComponentAttrs::passive_required())).unwrap();
+/// let chain: Chain<4> = v.try_into()?;
+/// ```
+#[derive(Clone, Debug)]
+pub struct Chain<const N: usize> {
+    entries: heapless::Vec<(ComponentId, ComponentAttrs), N>,
+}
+
+/// Why a `heapless::Vec` of components is not a valid [`Chain`].
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum ChainError {
+    /// The chain has no components.
+    Empty,
+    /// The chain is longer than `u8::MAX`, so `cursor` could not index it.
+    TooLong,
+    /// The same [`ComponentId`] appears more than once.
+    DuplicateId(ComponentId),
+    /// A `depends_on` names an id that is not in the chain.
+    UnknownDependency {
+        component: ComponentId,
+        depends_on: ComponentId,
+    },
+    /// A `depends_on` names a component that does not appear strictly earlier
+    /// in the chain (a forward reference or a self-reference). A dependency
+    /// must be walked before its dependents.
+    ForwardDependency {
+        component: ComponentId,
+        depends_on: ComponentId,
+    },
+}
+
+impl<const N: usize> Chain<N> {
+    /// Consume the validated chain, yielding its components in walk order.
+    fn into_entries(self) -> heapless::Vec<(ComponentId, ComponentAttrs), N> {
+        self.entries
+    }
+}
+
+impl<const N: usize> TryFrom<heapless::Vec<(ComponentId, ComponentAttrs), N>> for Chain<N> {
+    type Error = ChainError;
+
+    fn try_from(
+        entries: heapless::Vec<(ComponentId, ComponentAttrs), N>,
+    ) -> Result<Self, ChainError> {
+        if entries.is_empty() {
+            return Err(ChainError::Empty);
+        }
+        if entries.len() > u8::MAX as usize {
+            return Err(ChainError::TooLong);
+        }
+        for (i, (id, _)) in entries.iter().enumerate() {
+            if entries[..i].iter().any(|(prev, _)| prev == id) {
+                return Err(ChainError::DuplicateId(*id));
+            }
+        }
+        for (i, (id, attrs)) in entries.iter().enumerate() {
+            if let Some(dep) = attrs.depends_on {
+                match entries.iter().position(|(cid, _)| *cid == dep) {
+                    None => {
+                        return Err(ChainError::UnknownDependency {
+                            component: *id,
+                            depends_on: dep,
+                        });
+                    }
+                    Some(j) if j >= i => {
+                        return Err(ChainError::ForwardDependency {
+                            component: *id,
+                            depends_on: dep,
+                        });
+                    }
+                    Some(_) => {}
+                }
+            }
+        }
+        Ok(Self { entries })
+    }
+}
+
 /// Shared storage: data that persists across events. `N` is the chain capacity
 /// and `E` the effect-buffer size — both board choices; the core sets no
 /// default. `E` must be at least `N + 2` (enforced in [`Rot::new`]).
@@ -382,15 +477,12 @@
     /// Compile-time floor: the effect buffer must hold a full cascade (`N`
     /// `AssertReset`s) plus the destination `PreSupervision` entry's two
     /// effects. Forced by `new` below, so an under-sized `E` fails to build.
-    const EFFECT_CAP_OK: () = assert!(
-        E >= N + 2,
-        "effect buffer E must be >= chain length N + 2"
-    );
+    const EFFECT_CAP_OK: () = assert!(E >= N + 2, "effect buffer E must be >= chain length N + 2");
 
-    pub fn new(chain: heapless::Vec<(ComponentId, ComponentAttrs), N>, max_retry: u8) -> Self {
+    pub fn new(chain: Chain<N>, max_retry: u8) -> Self {
         let () = Self::EFFECT_CAP_OK;
         Self {
-            chain,
+            chain: chain.into_entries(),
             cursor: 0,
             gated: heapless::Vec::new(),
             failed: None,
@@ -404,7 +496,10 @@
     /// Look up a component's attributes by id. `None` if the id is not in the
     /// chain (should never happen for ids the core itself produced).
     fn attrs_of(&self, id: ComponentId) -> Option<ComponentAttrs> {
-        self.chain.iter().find(|(cid, _)| *cid == id).map(|(_, a)| *a)
+        self.chain
+            .iter()
+            .find(|(cid, _)| *cid == id)
+            .map(|(_, a)| *a)
     }
 
     fn is_gated(&self, id: ComponentId) -> bool {
@@ -545,7 +640,12 @@
 }
 
 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> {
+    fn call_handler(
+        &mut self,
+        rot: &mut Rot<N, E>,
+        event: &Event,
+        ctx: &mut Sink<E>,
+    ) -> Outcome<State> {
         match self {
             State::PowerOnReset => match event {
                 Event::PowerGood(PowerOnResult::Provisioned) => {
@@ -660,7 +760,10 @@
                     // 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.failed.map(|id| rot.bump_retry(id)).unwrap_or(rot.max_retry);
+                    let attempts = rot
+                        .failed
+                        .map(|id| rot.bump_retry(id))
+                        .unwrap_or(rot.max_retry);
                     if attempts < rot.max_retry {
                         Outcome::Transition(State::PreSupervision)
                     } else {
@@ -736,7 +839,12 @@
 }
 
 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> {
+    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 => {
@@ -763,7 +871,7 @@
 }
 
 impl<const N: usize, const E: usize> Orchestrator<N, E> {
-    pub fn new(chain: heapless::Vec<(ComponentId, ComponentAttrs), N>, max_retry: u8) -> Self {
+    pub fn new(chain: Chain<N>, max_retry: u8) -> Self {
         Self {
             machine: Rot::new(chain, max_retry).state_machine(),
         }
diff --git a/services/orchestrator/sm/src/tests.rs b/services/orchestrator/sm/src/tests.rs
index ba303e9..f17c118 100644
--- a/services/orchestrator/sm/src/tests.rs
+++ b/services/orchestrator/sm/src/tests.rs
@@ -56,7 +56,8 @@
     chain: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY>,
     script: &[Event],
 ) -> (Vec<Effect>, State) {
-    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(chain, MAX_RETRY);
+    let mut orch =
+        Orchestrator::<CAPACITY, ECAP>::new(chain.try_into().expect("valid chain"), MAX_RETRY);
     let mut platform = Recorder::new();
     for &event in script {
         orch.dispatch(&mut platform, event);
@@ -238,7 +239,7 @@
     let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new();
     c.push((C0, ComponentAttrs::passive_required()))
         .expect("fits");
-    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 2);
+    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 2);
     let mut effects = Vec::new();
 
     for ev in [
@@ -277,7 +278,7 @@
         .expect("fits");
     c.push((C1, ComponentAttrs::passive_required()))
         .expect("fits");
-    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 2);
+    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 2);
     let mut effects = Vec::new();
 
     for ev in [
@@ -304,7 +305,7 @@
     let mut c = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new();
     c.push((C0, ComponentAttrs::passive_required()))
         .expect("fits");
-    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 1);
+    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 1);
     let mut effects = Vec::new();
     for ev in [
         BOOT,
@@ -326,7 +327,7 @@
         c.push((id, ComponentAttrs::passive_required()))
             .expect("3 fits");
     }
-    let mut orch = Orchestrator::<3, 5>::new(c, MAX_RETRY);
+    let mut orch = Orchestrator::<3, 5>::new(c.try_into().expect("valid chain"), MAX_RETRY);
     let mut effects = Vec::new();
     for ev in [
         BOOT,
@@ -726,7 +727,7 @@
     let mut c: heapless::Vec<(ComponentId, ComponentAttrs), CAPACITY> = heapless::Vec::new();
     c.push((C0, ComponentAttrs::passive_required())).unwrap();
     // max_retry = 1 so the first failed restore latches immediately.
-    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c, 1);
+    let mut orch = Orchestrator::<CAPACITY, ECAP>::new(c.try_into().expect("valid chain"), 1);
     let mut effects: Vec<Effect> = Vec::new();
 
     for ev in [BOOT, Event::VerificationFailed(C0), Event::Restored(C0)] {
@@ -786,7 +787,9 @@
         chain(&[
             (C0, ComponentAttrs::active_required()),
             (C1, ComponentAttrs::passive_required()),
-        ]),
+        ])
+        .try_into()
+        .expect("valid chain"),
         MAX_RETRY,
     );
     let mut effects: Vec<Effect> = Vec::new();
@@ -831,3 +834,74 @@
         ],
     );
 }
+
+/// An empty component list is not a valid chain of trust.
+#[test]
+fn chain_rejects_empty() {
+    let empty = heapless::Vec::<(ComponentId, ComponentAttrs), CAPACITY>::new();
+    assert_eq!(Chain::try_from(empty).unwrap_err(), ChainError::Empty);
+}
+
+/// A repeated `ComponentId` is rejected: the reducer's linear id lookups would
+/// otherwise be ambiguous.
+#[test]
+fn chain_rejects_duplicate_id() {
+    let v = chain(&[
+        (C0, ComponentAttrs::passive_required()),
+        (C0, ComponentAttrs::passive_required()),
+    ]);
+    assert_eq!(Chain::try_from(v).unwrap_err(), ChainError::DuplicateId(C0),);
+}
+
+/// A `depends_on` that names a component not in the chain is rejected.
+#[test]
+fn chain_rejects_unknown_dependency() {
+    let v = chain(&[(C1, ComponentAttrs::passive_required().with_depends_on(C0))]);
+    assert_eq!(
+        Chain::try_from(v).unwrap_err(),
+        ChainError::UnknownDependency {
+            component: C1,
+            depends_on: C0,
+        },
+    );
+}
+
+/// A dependency must appear strictly earlier in the walk than its dependent;
+/// a forward reference is rejected.
+#[test]
+fn chain_rejects_forward_dependency() {
+    let v = chain(&[
+        (C0, ComponentAttrs::passive_required().with_depends_on(C1)),
+        (C1, ComponentAttrs::passive_cascading()),
+    ]);
+    assert_eq!(
+        Chain::try_from(v).unwrap_err(),
+        ChainError::ForwardDependency {
+            component: C0,
+            depends_on: C1,
+        },
+    );
+}
+
+/// A component may not depend on itself.
+#[test]
+fn chain_rejects_self_dependency() {
+    let v = chain(&[(C0, ComponentAttrs::passive_required().with_depends_on(C0))]);
+    assert_eq!(
+        Chain::try_from(v).unwrap_err(),
+        ChainError::ForwardDependency {
+            component: C0,
+            depends_on: C0,
+        },
+    );
+}
+
+/// A well-formed chain with a backward dependency validates successfully.
+#[test]
+fn chain_accepts_valid_dependency() {
+    let v = chain(&[
+        (C0, ComponentAttrs::passive_cascading()),
+        (C1, ComponentAttrs::passive_required().with_depends_on(C0)),
+    ]);
+    assert!(Chain::try_from(v).is_ok());
+}