orchestrator: Compose report delivery from the board's ReportSink capability

BoardCapabilities gains ReportSink and Board a report_sink field, and the
four Report effects leave the not-yet-composed group to hand their Report
to the sink.

One sink per platform, not one per component: two of the four reports
name no component.

Driver tests build their Board through mock_board() and override only the
field under test; a seventh field would otherwise be repeated in fourteen
literals.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
diff --git a/services/orchestrator/driver/src/board.rs b/services/orchestrator/driver/src/board.rs
index f471a28..dfed222 100644
--- a/services/orchestrator/driver/src/board.rs
+++ b/services/orchestrator/driver/src/board.rs
@@ -166,6 +166,8 @@
     /// survives reset and power loss, otherwise a power cycle would
     /// re-admit images below the floor.
     type SvnFloor: SvnFloor;
+    /// Where reports go. `()` for a board with no management side to tell.
+    type ReportSink: ReportSink;
     // Later seams: Recovery, Staging.
 }
 
@@ -193,6 +195,7 @@
 ///     type BootControl = ExtrstGpio;      // per-component reset line
 ///     type BootWatch = CheckpointWalk;    // GPIO checkpoint walk over the boot window
 ///     type SvnFloor = OtpSvnFloor;        // fuse-backed anti-rollback floor
+///     type ReportSink = MctpReports;      // reports out over the management transport
 /// }
 /// let board = Board::<Ast1060Board, 2> {
 ///     images: [bmc_image, cpld_image],
@@ -201,6 +204,7 @@
 ///     boot_watches: [bmc_walk, cpld_walk],
 ///     component_kinds: [ComponentKind::Active, ComponentKind::Passive],
 ///     svn_floors: [SvnFloorBinding::Erot(bmc_floor), SvnFloorBinding::SelfManaged],
+///     report_sink,
 /// };
 /// ```
 pub struct Board<B: BoardCapabilities, const N: usize> {
@@ -222,5 +226,8 @@
     /// `svn_floors[i]` says who keeps `ComponentId(i)`'s anti-rollback
     /// floor, same indexing as `images`.
     pub svn_floors: [SvnFloorBinding<B::SvnFloor>; N],
+    /// Where the driver hands the SM's reports. One per platform, not one
+    /// per component: two of the four reports name no component.
+    pub report_sink: B::ReportSink,
     // 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 1a14d41..0176f0b 100644
--- a/services/orchestrator/driver/src/driver.rs
+++ b/services/orchestrator/driver/src/driver.rs
@@ -6,7 +6,9 @@
 
 use openprot_orchestrator_sm::{ComponentId, ComponentKind, Effect, EffectError, Event, Platform};
 
-use crate::board::{Board, BoardCapabilities, ImageSource, SvnFloorBinding, Verdict, Verifier};
+use crate::board::{
+    Board, BoardCapabilities, ImageSource, Report, ReportSink, SvnFloorBinding, Verdict, Verifier,
+};
 use orchestrator_capabilities::{BootControl, BootWatch, Svn, SvnFloor, WalkVerdict};
 
 /// Why the driver could not carry out an effect.
@@ -232,6 +234,13 @@
             next_deadline_millis,
         }
     }
+
+    /// Hands one report to the board's sink. Cannot fail, so reporting stays
+    /// off the fail-closed path; reports arrive in the order the SM emitted
+    /// them.
+    pub fn report(&mut self, report: Report) {
+        self.board.report_sink.report(report);
+    }
 }
 
 /// One [`PlatformDriver::poll_boot_walks`] round.
@@ -259,24 +268,37 @@
             Effect::ReleaseReset(id) => self.release_reset(id).map(|_| None),
             Effect::AssertReset(id) => self.assert_reset(id).map(|_| None),
             Effect::CommitSvnFloor(id) => self.commit_svn_floor(id).map(|_| None),
+            // Reports carry no error, so they never reach the fail-closed
+            // group below.
+            Effect::ReportIsolated(id) => {
+                self.report(Report::Isolated(id));
+                Ok(None)
+            }
+            Effect::ReportRecoveryFailed(id) => {
+                self.report(Report::RecoveryFailed(id));
+                Ok(None)
+            }
+            Effect::ReportUpdateDeferred => {
+                self.report(Report::UpdateDeferred);
+                Ok(None)
+            }
+            Effect::ReportUpdateAborted => {
+                self.report(Report::UpdateAborted);
+                Ok(None)
+            }
             // No board capability is composed for these seams yet, so they
             // fail closed here instead of behind stub methods. Each group
             // gains an executor when its capability joins
             // [`BoardCapabilities`], as BootControl did above: recovery
             // sourcing for RecoverComponent; update staging, authentication
             // and trial activation for the update quartet; evidence signing
-            // for SignAttestation; the management reporting path for the
-            // Report effects; the terminal latch for LatchLockdown.
+            // for SignAttestation; the terminal latch for LatchLockdown.
             Effect::RecoverComponent { .. }
             | Effect::AuthenticateUpdate
             | Effect::StageUpdate
             | Effect::ActivateUpdate
             | Effect::DiscardStaged
             | Effect::SignAttestation
-            | Effect::ReportIsolated(_)
-            | Effect::ReportRecoveryFailed(_)
-            | Effect::ReportUpdateDeferred
-            | Effect::ReportUpdateAborted
             | Effect::LatchLockdown => return Err(EffectError),
             // Emit is consumed by the orchestrator; receiving one is a
             // driver bug.
diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs
index c18b82f..1dc9f00 100644
--- a/services/orchestrator/driver/src/tests.rs
+++ b/services/orchestrator/driver/src/tests.rs
@@ -301,19 +301,34 @@
     type BootControl = MockReset;
     type BootWatch = MockWalk;
     type SvnFloor = MockFloor;
+    type ReportSink = RecordingSink;
+}
+
+/// The SVN `mock_board`'s verifier vouches for. Tests that read the floor
+/// back assert against it.
+const MOCK_SVN: u32 = 5;
+
+/// Happy-path wiring for `N` components. Tests override the one field
+/// they exercise with `..mock_board()`.
+fn mock_board<const N: usize>() -> Board<MockBoard, N> {
+    Board {
+        images: core::array::from_fn(|_| MemImage::holding(valid_image())),
+        verifier: XorVerifier {
+            fault: false,
+            svn: MOCK_SVN,
+        },
+        boot_controls: core::array::from_fn(|_| MockReset::new()),
+        boot_watches: core::array::from_fn(|_| MockWalk::idle()),
+        component_kinds: core::array::from_fn(|_| ComponentKind::Passive),
+        svn_floors: core::array::from_fn(|_| SvnFloorBinding::Erot(MockFloor::new())),
+        report_sink: RecordingSink::new(),
+    }
 }
 
 fn driver(images: [MemImage; 1]) -> PlatformDriver<MockBoard, 1> {
     PlatformDriver::new(Board {
         images,
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     })
 }
 
@@ -392,15 +407,11 @@
 fn verifier_fault_fails_closed() {
     let mut orch = orchestrator();
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
         verifier: XorVerifier {
             fault: true,
-            svn: 5,
+            svn: MOCK_SVN,
         },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
 
     orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned));
@@ -412,23 +423,7 @@
 
 #[test]
 fn verify_for_a_different_component_is_refused() {
-    let mut driver = PlatformDriver::<MockBoard, 2>::new(Board {
-        images: [
-            MemImage::holding(valid_image()),
-            MemImage::holding(valid_image()),
-        ],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new(), MockReset::new()],
-        boot_watches: [MockWalk::idle(), MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive, ComponentKind::Passive],
-        svn_floors: [
-            SvnFloorBinding::Erot(MockFloor::new()),
-            SvnFloorBinding::Erot(MockFloor::new()),
-        ],
-    });
+    let mut driver = PlatformDriver::<MockBoard, 2>::new(mock_board());
 
     driver.stage_firmware(C0).unwrap();
 
@@ -458,17 +453,7 @@
 
 #[test]
 fn reset_release_and_assert_reach_the_boot_control() {
-    let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
-    });
+    let mut driver = PlatformDriver::<MockBoard, 1>::new(mock_board());
 
     driver.release_reset(C0).unwrap();
     assert!(!driver.board().boot_controls[0].held.get());
@@ -496,15 +481,8 @@
     let mut control = MockReset::new();
     control.fail = true;
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
         boot_controls: [control],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
 
     assert_eq!(driver.release_reset(C0), Err(DriverError::BootControlFault));
@@ -568,6 +546,8 @@
     type BootControl = MockReset;
     type BootWatch = MockWalk;
     type SvnFloor = MockFloor;
+    // A board with nothing to tell: exercises the no-op sink.
+    type ReportSink = ();
 }
 
 // The at-rest guarantee end to end: the component is still held while its
@@ -582,7 +562,7 @@
         verifier: LineWatchingVerifier {
             inner: XorVerifier {
                 fault: false,
-                svn: 5,
+                svn: MOCK_SVN,
             },
             line: held.clone(),
             held_during_verify: held_during_verify.clone(),
@@ -591,6 +571,7 @@
         boot_watches: [MockWalk::idle()],
         component_kinds: [ComponentKind::Passive],
         svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        report_sink: (),
     });
     let mut orch = orchestrator();
 
@@ -612,15 +593,8 @@
     control.fail = true;
     let held = control.held.clone();
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
         boot_controls: [control],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
     let mut orch = orchestrator();
 
@@ -641,21 +615,9 @@
     component_kinds: [ComponentKind; 2],
 ) -> PlatformDriver<MockBoard, 2> {
     PlatformDriver::new(Board {
-        images: [
-            MemImage::holding(valid_image()),
-            MemImage::holding(valid_image()),
-        ],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new(), MockReset::new()],
         boot_watches: walks,
         component_kinds,
-        svn_floors: [
-            SvnFloorBinding::Erot(MockFloor::new()),
-            SvnFloorBinding::Erot(MockFloor::new()),
-        ],
+        ..mock_board()
     })
 }
 
@@ -834,15 +796,8 @@
 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,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
         boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Complete])],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
 
     orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned));
@@ -863,18 +818,11 @@
 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,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
         boot_watches: [MockWalk::scripted(std::vec![WalkVerdict::Failed {
             checkpoint: "heartbeat",
             cause: FailureCause::TimedOut,
         }])],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
 
     orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned));
@@ -892,16 +840,15 @@
 // ---------------------------------------------------------------------------
 
 /// Records what it is handed: the seam satisfied without a management
-/// transport.
+/// transport. Tests read `seen` back through `PlatformDriver::board`.
+#[derive(Default)]
 struct RecordingSink {
     seen: std::vec::Vec<Report>,
 }
 
 impl RecordingSink {
     fn new() -> Self {
-        Self {
-            seen: std::vec::Vec::new(),
-        }
+        Self::default()
     }
 }
 
@@ -946,17 +893,7 @@
 // after a verification has passed — the two halves of the commit contract.
 #[test]
 fn commit_advances_the_floor_to_the_verified_svn() {
-    let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
-    });
+    let mut driver = PlatformDriver::<MockBoard, 1>::new(mock_board());
 
     driver
         .execute(Effect::ReadFirmware(C0))
@@ -972,7 +909,7 @@
     };
     assert_eq!(
         floor.floor(),
-        Ok(Svn(5)),
+        Ok(Svn(MOCK_SVN)),
         "floor advanced to the verifier's SVN"
     );
 }
@@ -983,15 +920,8 @@
 #[test]
 fn commit_without_an_erot_floor_is_a_no_op() {
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
         svn_floors: [SvnFloorBinding::SelfManaged],
+        ..mock_board()
     });
 
     assert_eq!(driver.execute(Effect::CommitSvnFloor(C0)), Ok(None));
@@ -1015,14 +945,7 @@
     corrupt[7] ^= 0x01;
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
         images: [MemImage::holding(valid_image()).reflash_on_reopen(corrupt)],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
-        svn_floors: [SvnFloorBinding::Erot(MockFloor::new())],
+        ..mock_board()
     });
 
     driver.stage_firmware(C0).expect("stage failed");
@@ -1050,15 +973,8 @@
     let mut mock = MockFloor::new();
     mock.fail = true;
     let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
-        images: [MemImage::holding(valid_image())],
-        verifier: XorVerifier {
-            fault: false,
-            svn: 5,
-        },
-        boot_controls: [MockReset::new()],
-        boot_watches: [MockWalk::idle()],
-        component_kinds: [ComponentKind::Passive],
         svn_floors: [SvnFloorBinding::Erot(mock)],
+        ..mock_board()
     });
 
     driver.stage_firmware(C0).expect("stage failed");
@@ -1066,3 +982,57 @@
 
     assert_eq!(driver.commit_svn_floor(C0), Err(DriverError::SvnFloorFault));
 }
+
+// Every report effect reaches the board's sink, in emission order, and none
+// hands back an error for the SM to fail closed on.
+#[test]
+fn reports_reach_the_board_sink() {
+    let mut driver = PlatformDriver::<MockBoard, 1>::new(Board {
+        verifier: XorVerifier {
+            fault: false,
+            svn: 0,
+        },
+        ..mock_board()
+    });
+
+    for effect in [
+        Effect::ReportIsolated(C0),
+        Effect::ReportRecoveryFailed(C0),
+        Effect::ReportUpdateDeferred,
+        Effect::ReportUpdateAborted,
+    ] {
+        assert_eq!(driver.execute(effect), Ok(None));
+    }
+
+    assert_eq!(driver.board().report_sink.seen, every_report());
+}
+
+// An Isolable component is contained and reported, and the platform keeps
+// running: executing a report returns no error, so it never reaches the
+// fail-closed path.
+#[test]
+fn reporting_an_isolated_component_does_not_lock_the_platform() {
+    let mut driver = PlatformDriver::<MockBoard, 2>::new(Board {
+        verifier: XorVerifier {
+            fault: false,
+            svn: 0,
+        },
+        ..mock_board()
+    });
+    let mut chain = heapless::Vec::<_, 2>::new();
+    chain
+        .push((C0, ComponentAttrs::passive_required()))
+        .unwrap();
+    chain
+        .push((C1, ComponentAttrs::passive_isolable()))
+        .unwrap();
+    let mut orch = Orchestrator::<2, 6>::new(chain.try_into().unwrap(), 3);
+
+    orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned));
+    assert_eq!(orch.state(), State::Ready, "both components verified");
+
+    orch.dispatch(&mut driver, Event::CorruptionDetected(C1));
+
+    assert_eq!(orch.state(), State::Ready, "contained, not locked");
+    assert_eq!(driver.board().report_sink.seen, [Report::Isolated(C1)]);
+}