fwmanager: Add BootMonitor capability and GpioBootMonitor adapter

BootMonitor reports a device's boot liveness as BootStatus in the api
leaf crate; GpioBootMonitor implements it over one HAL GpioPort input
line. MonitorError meets the core::error::Error bound and keeps the
concrete HAL error reachable through source(). new() rejects empty pin
masks; boot latches are cleared by the reset path, never the observer.

Closes: https://github.com/9elements/openprot/issues/6
Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
diff --git a/services/fwmanager/api/BUILD.bazel b/services/fwmanager/api/BUILD.bazel
index 7cec74f..49662ed 100644
--- a/services/fwmanager/api/BUILD.bazel
+++ b/services/fwmanager/api/BUILD.bazel
@@ -7,6 +7,7 @@
     name = "fwmanager_api",
     srcs = [
         "src/boot_control.rs",
+        "src/boot_monitor.rs",
         "src/config.rs",
         "src/lib.rs",
     ],
diff --git a/services/fwmanager/api/src/boot_monitor.rs b/services/fwmanager/api/src/boot_monitor.rs
new file mode 100644
index 0000000..931970e
--- /dev/null
+++ b/services/fwmanager/api/src/boot_monitor.rs
@@ -0,0 +1,175 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Observation capability: read a managed device's boot liveness.
+
+/// Liveness of a managed device's boot: Boot Confirmation only.
+///
+/// Reports only that a device came up, never what booted; confirming the
+/// running image is the one the RoT staged is attestation, a separate step.
+/// `Failed` is optional device-reported evidence and never the only failure
+/// path, since a hung device reports nothing — a stuck boot is caught by the
+/// orchestrator's timeout, not by this enum.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BootStatus {
+    /// Held in reset by the orchestrator; not yet released.
+    InReset,
+    /// Released, but boot completion not yet observed.
+    Booting,
+    /// Boot completion observed.
+    Booted,
+    /// Device reported a boot failure.
+    Failed,
+}
+
+/// Observation capability: read a managed device's boot liveness.
+///
+/// Pull-shaped: where the underlying signal is an edge or pulse, the interrupt
+/// latches a flag beneath this seam and `boot_status` only reads it. No
+/// callback registration, which would require allocation and invert control
+/// into device implementations.
+///
+/// The reported status must describe the **current** boot cycle. An
+/// implementation backed by a latched signal must guarantee the latch is
+/// cleared whenever the device re-enters reset, so evidence left over from a
+/// previous boot never reads as [`BootStatus::Booted`]. This trait
+/// deliberately has no re-arm operation: clearing is the reset path's job
+/// (hardware tying the latch to the device's reset line, or the same platform
+/// code that drives `BootControl`), not the observer's — a monitor that could
+/// clear its own evidence would let a read race a reset.
+pub trait BootMonitor {
+    /// The error type reported by this device's boot monitor.
+    ///
+    /// Requires [`core::error::Error`] (in `core` since Rust 1.81) so the
+    /// orchestrator gets `Display` and a `source()` cause chain, not just a
+    /// `Debug` dump. Error categories stay implementation-defined — this
+    /// crate names no error vocabulary of its own; a consumer that knows the
+    /// concrete adapter can recover its details by downcasting the
+    /// `&dyn core::error::Error`.
+    type Error: core::error::Error;
+
+    /// Returns the current liveness of the device.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the underlying liveness signal cannot be read.
+    fn boot_status(&self) -> Result<BootStatus, Self::Error>;
+}
+
+#[cfg(test)]
+#[allow(clippy::bool_assert_comparison)]
+mod tests {
+    use super::*;
+    use core::cell::Cell;
+
+    // ── Trait contract ──────────────────────────────────────────────────
+    // MockMonitor implements the trait without any HAL dependency. If a
+    // HAL-specific bound sneaks back onto `Error`, this module stops
+    // compiling.
+
+    struct MockMonitor {
+        ready_after: usize,
+        polls: Cell<usize>,
+        fail: bool,
+    }
+
+    #[derive(Debug, PartialEq, Eq)]
+    struct MockFault;
+
+    impl core::fmt::Display for MockFault {
+        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+            f.write_str("mock monitor fault")
+        }
+    }
+
+    impl core::error::Error for MockFault {}
+
+    impl BootMonitor for MockMonitor {
+        type Error = MockFault;
+
+        fn boot_status(&self) -> Result<BootStatus, MockFault> {
+            if self.fail {
+                return Err(MockFault);
+            }
+            let polls = self.polls.get();
+            self.polls.set(polls + 1);
+            Ok(if polls >= self.ready_after {
+                BootStatus::Booted
+            } else {
+                BootStatus::Booting
+            })
+        }
+    }
+
+    // A device that is still coming up reads Booting, then Booted once it
+    // is up.
+    #[test]
+    fn status_progresses_from_booting_to_booted() {
+        let mon = MockMonitor {
+            ready_after: 1,
+            polls: Cell::new(0),
+            fail: false,
+        };
+
+        assert_eq!(
+            mon.boot_status().expect("boot_status failed"),
+            BootStatus::Booting
+        );
+        assert_eq!(
+            mon.boot_status().expect("boot_status failed"),
+            BootStatus::Booted
+        );
+    }
+
+    #[test]
+    fn errors_surface_through_the_generic_seam() {
+        let mon = MockMonitor {
+            ready_after: 0,
+            polls: Cell::new(0),
+            fail: true,
+        };
+
+        let err = comes_up_within(&mon, 1).expect_err("expected the monitor fault");
+
+        // Display comes from the core::error::Error bound, not a Debug dump.
+        assert_eq!(err.to_string(), "mock monitor fault");
+    }
+
+    // ── The orchestrator's future shape ─────────────────────────────────
+    // Usage examples for the future orchestrator, not API guarantees; move
+    // these to the orchestrator crate once it exists.
+
+    /// Poll a monitor up to `poll_budget` times. `Booting` is not a failure;
+    /// `Ok(false)` means the budget ran out before the device came up.
+    fn comes_up_within<M: BootMonitor>(mon: &M, poll_budget: usize) -> Result<bool, M::Error> {
+        for _ in 0..poll_budget {
+            if mon.boot_status()? == BootStatus::Booted {
+                return Ok(true);
+            }
+        }
+        Ok(false)
+    }
+
+    // A device that comes up within the poll budget reads Booted.
+    #[test]
+    fn a_device_that_comes_up_within_budget_is_booted() {
+        let mon = MockMonitor {
+            ready_after: 2,
+            polls: Cell::new(0),
+            fail: false,
+        };
+
+        assert_eq!(comes_up_within(&mon, 5).expect("boot_status failed"), true);
+    }
+
+    #[test]
+    fn a_device_that_never_comes_up_is_a_timeout_not_an_error() {
+        let mon = MockMonitor {
+            ready_after: usize::MAX,
+            polls: Cell::new(0),
+            fail: false,
+        };
+
+        assert_eq!(comes_up_within(&mon, 3).expect("boot_status failed"), false);
+    }
+}
diff --git a/services/fwmanager/api/src/lib.rs b/services/fwmanager/api/src/lib.rs
index 72d53ef..f379d10 100644
--- a/services/fwmanager/api/src/lib.rs
+++ b/services/fwmanager/api/src/lib.rs
@@ -7,18 +7,24 @@
 //! single managed device's reset without knowing which controller line it
 //! maps to.
 //!
+//! `BootMonitor` is the observation capability: the orchestrator reads a
+//! device's boot liveness.
+//!
 //! This crate is a dependency-free leaf: it holds the capability contracts
 //! and the schema for the per-board device table, and everything depends
 //! downward on it. Concrete adapters bind a trait to a signal source and
 //! live in their own crates, so naming a capability never drags in the stack
-//! behind it — the HAL-backed `HalBootControl` is in
-//! `fwmanager-hal-adapters`; other backends implement the same trait from
-//! their own transport crate. Config values live in the board device tables
+//! behind it — the HAL-backed `HalBootControl` and `GpioBootMonitor` are in
+//! `fwmanager-hal-adapters`; other backends (for example an MCTP-ready
+//! `BootMonitor`) implement the same traits from their own transport crate.
+//! Config values live in the board device tables
 //! (`target/<board>/devices.rs`).
 
 #![cfg_attr(not(test), no_std)]
 
 mod boot_control;
+mod boot_monitor;
 pub mod config;
 
 pub use boot_control::BootControl;
+pub use boot_monitor::{BootMonitor, BootStatus};
diff --git a/services/fwmanager/hal-adapters/BUILD.bazel b/services/fwmanager/hal-adapters/BUILD.bazel
index aff19e3..14e65f0 100644
--- a/services/fwmanager/hal-adapters/BUILD.bazel
+++ b/services/fwmanager/hal-adapters/BUILD.bazel
@@ -6,6 +6,7 @@
 rust_library(
     name = "fwmanager_hal_adapters",
     srcs = [
+        "src/gpio_boot_monitor.rs",
         "src/hal_boot_control.rs",
         "src/lib.rs",
     ],
diff --git a/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs
new file mode 100644
index 0000000..b4c2284
--- /dev/null
+++ b/services/fwmanager/hal-adapters/src/gpio_boot_monitor.rs
@@ -0,0 +1,350 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! HAL-backed [`BootMonitor`]: read a device's boot-complete signal off a GPIO
+//! input line.
+
+use fwmanager_api::{BootMonitor, BootStatus};
+use openprot_hal_blocking::gpio_port::{
+    ActivePolarity, GpioError, GpioErrorKind, GpioPort, PinMask,
+};
+
+/// Adapts any HAL GPIO error into a [`core::error::Error`].
+///
+/// GPIO ports keep implementing the HAL `GpioError`/`kind()` pattern
+/// unchanged; this wrapper supplies the `Display` and `core::error::Error`
+/// machinery [`BootMonitor::Error`] requires, so no per-implementation work is
+/// needed. The underlying category stays reachable via [`MonitorError::kind`],
+/// and the concrete HAL error through the
+/// [`source()`](core::error::Error::source) chain, downcast to
+/// [`GpioCause<E>`](GpioCause).
+#[derive(Debug)]
+pub struct MonitorError<E>(GpioCause<E>);
+
+/// The concrete HAL error underneath a [`MonitorError`], surfaced as its
+/// [`source()`](core::error::Error::source).
+///
+/// `GpioError` only guarantees `Debug`, so the HAL error cannot be the source
+/// itself; this wrapper adds the `Display`/`Error` machinery. It renders the
+/// full concrete error, not just its kind — the tail of a rendered error
+/// chain carries the implementation's details.
+#[derive(Debug)]
+pub struct GpioCause<E>(pub E);
+
+impl<E: GpioError> core::fmt::Display for MonitorError<E> {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "boot monitor error: {:?}", self.kind())
+    }
+}
+
+impl<E: GpioError + 'static> core::error::Error for MonitorError<E> {
+    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
+        Some(&self.0)
+    }
+}
+
+impl<E: GpioError> core::fmt::Display for GpioCause<E> {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "{:?}", self.0)
+    }
+}
+
+impl<E: GpioError + 'static> core::error::Error for GpioCause<E> {}
+
+impl<E: GpioError> GpioError for MonitorError<E> {
+    fn kind(&self) -> GpioErrorKind {
+        let GpioCause(inner) = &self.0;
+        inner.kind()
+    }
+}
+
+impl<E: GpioError> From<E> for MonitorError<E> {
+    fn from(err: E) -> Self {
+        Self(GpioCause(err))
+    }
+}
+
+/// Binds one input line of a HAL GPIO port to a managed device's boot-complete
+/// signal.
+///
+/// The input-side counterpart to [`HalBootControl`]: the `(port, ready_pin,
+/// active)` binding is made once, in platform configuration, and the
+/// orchestrator never learns which line belongs to which device. A signal that
+/// latches high is read directly; an edge or pulse (for example a heartbeat) is
+/// latched in hardware at bring-up so `read_input` still reflects it.
+///
+/// Where a hardware latch is used, the platform must clear it whenever the
+/// device re-enters reset (typically by wiring the latch's clear to the
+/// device's reset line) — [`BootMonitor`] requires that evidence from a
+/// previous boot never reads as [`BootStatus::Booted`], and this adapter only
+/// reads the line, it cannot re-arm it.
+///
+/// [`HalBootControl`]: crate::HalBootControl
+pub struct GpioBootMonitor<P: GpioPort> {
+    port: P,
+    ready_pin: P::Mask,
+    active: ActivePolarity,
+}
+
+impl<P: GpioPort> GpioBootMonitor<P> {
+    /// Binds `port`'s line `ready_pin`, asserted per `active`, as a device's
+    /// boot-complete signal.
+    ///
+    /// `ready_pin` must name exactly one line — boot-complete is a single
+    /// signal. Masks naming several lines are not supported (the `PinMask`
+    /// contract offers no way to reject them here, so they are unspecified
+    /// behavior, not a feature).
+    ///
+    /// # Panics
+    ///
+    /// Panics if `ready_pin` is empty. An empty mask is vacuously contained
+    /// in every sample, so it would report every device `Booted` from the
+    /// moment reset is released — a misbinding that must fail at
+    /// construction, in platform bring-up, not stay silent in the field.
+    pub fn new(port: P, ready_pin: P::Mask, active: ActivePolarity) -> Self {
+        assert!(
+            !ready_pin.is_empty(),
+            "GpioBootMonitor bound to an empty pin mask"
+        );
+        Self {
+            port,
+            ready_pin,
+            active,
+        }
+    }
+}
+
+// `P::Error: 'static` because `source()` hands out `&(dyn Error + 'static)`
+// referencing the wrapped HAL error. Error types are plain data; this costs
+// no real implementation anything.
+impl<P: GpioPort> BootMonitor for GpioBootMonitor<P>
+where
+    P::Error: 'static,
+{
+    type Error = MonitorError<P::Error>;
+
+    /// # Errors
+    ///
+    /// Propagates any error returned by the port's `read_input`.
+    fn boot_status(&self) -> Result<BootStatus, Self::Error> {
+        let high = self.port.read_input()?.contains(self.ready_pin);
+        let booted = match self.active {
+            ActivePolarity::ActiveHigh => high,
+            ActivePolarity::ActiveLow => !high,
+        };
+        Ok(if booted {
+            BootStatus::Booted
+        } else {
+            BootStatus::Booting
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use openprot_hal_blocking::gpio_port::GpioErrorType;
+
+    // BMC boot-complete on line 4. Normally set in config.rs.
+    const BMC_READY: Mask = Mask(1 << 4);
+
+    /// Bitmask over a single mock GPIO bank.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+    struct Mask(u8);
+
+    impl PinMask for Mask {
+        fn empty() -> Self {
+            Self(0)
+        }
+
+        fn all() -> Self {
+            Self(u8::MAX)
+        }
+
+        fn is_empty(&self) -> bool {
+            self.0 == 0
+        }
+
+        fn contains(&self, other: Self) -> bool {
+            self.0 & other.0 == other.0
+        }
+
+        fn union(&self, other: Self) -> Self {
+            Self(self.0 | other.0)
+        }
+
+        fn intersection(&self, other: Self) -> Self {
+            Self(self.0 & other.0)
+        }
+
+        fn toggle(&self) -> Self {
+            Self(!self.0)
+        }
+    }
+
+    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+    struct MockError(GpioErrorKind);
+
+    impl GpioError for MockError {
+        fn kind(&self) -> GpioErrorKind {
+            self.0
+        }
+    }
+
+    /// Mock HAL GPIO port: returns a fixed input sample, with opt-in read
+    /// failure injection. Output-side methods must never be called.
+    struct MockGpioPort {
+        input: Mask,
+        fail: Option<GpioErrorKind>,
+    }
+
+    impl MockGpioPort {
+        fn with_input(input: Mask) -> Self {
+            Self { input, fail: None }
+        }
+
+        fn failing(kind: GpioErrorKind) -> Self {
+            Self {
+                input: Mask::empty(),
+                fail: Some(kind),
+            }
+        }
+    }
+
+    impl GpioErrorType for MockGpioPort {
+        type Error = MockError;
+    }
+
+    impl GpioPort for MockGpioPort {
+        type Config = ();
+        type Mask = Mask;
+
+        fn read_input(&self) -> Result<Mask, MockError> {
+            match self.fail {
+                Some(kind) => Err(MockError(kind)),
+                None => Ok(self.input),
+            }
+        }
+
+        fn configure(&mut self, _: Mask, _: ()) -> Result<(), MockError> {
+            panic!("BootMonitor must never configure pins");
+        }
+
+        fn set_reset(&mut self, _: Mask, _: Mask) -> Result<(), MockError> {
+            panic!("BootMonitor must never drive outputs");
+        }
+
+        fn toggle(&mut self, _: Mask) -> Result<(), MockError> {
+            panic!("BootMonitor must never drive outputs");
+        }
+    }
+
+    // A ready line asserted high reads as Booted under active-high polarity.
+    #[test]
+    fn booted_when_ready_line_asserted() {
+        let mon = GpioBootMonitor::new(
+            MockGpioPort::with_input(BMC_READY),
+            BMC_READY,
+            ActivePolarity::ActiveHigh,
+        );
+
+        assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted);
+    }
+
+    // A deasserted ready line is not a failure — the device is still Booting
+    // until the orchestrator's own timeout fires.
+    #[test]
+    fn booting_when_ready_line_deasserted() {
+        let mon = GpioBootMonitor::new(
+            MockGpioPort::with_input(Mask::empty()),
+            BMC_READY,
+            ActivePolarity::ActiveHigh,
+        );
+
+        assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting);
+    }
+
+    // Active-low inverts the sense: a low line is asserted, hence Booted.
+    #[test]
+    fn respects_active_low_polarity() {
+        let mon = GpioBootMonitor::new(
+            MockGpioPort::with_input(Mask::empty()),
+            BMC_READY,
+            ActivePolarity::ActiveLow,
+        );
+
+        assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booted);
+    }
+
+    // Only the configured line matters: an unrelated high line leaves the
+    // device Booting.
+    #[test]
+    fn ignores_other_lines() {
+        let mon = GpioBootMonitor::new(
+            MockGpioPort::with_input(Mask(1 << 2)),
+            BMC_READY,
+            ActivePolarity::ActiveHigh,
+        );
+
+        assert_eq!(mon.boot_status().expect("read failed"), BootStatus::Booting);
+    }
+
+    // An empty mask would read as Booted forever (it is vacuously contained
+    // in every sample); binding one must die at construction.
+    #[test]
+    #[should_panic(expected = "empty pin mask")]
+    fn empty_ready_mask_panics_at_construction() {
+        GpioBootMonitor::new(
+            MockGpioPort::with_input(Mask::empty()),
+            Mask::empty(),
+            ActivePolarity::ActiveHigh,
+        );
+    }
+
+    // A controller error surfaces through BootMonitor unchanged.
+    #[test]
+    fn port_error_propagates_through_boot_monitor() {
+        let mon = GpioBootMonitor::new(
+            MockGpioPort::failing(GpioErrorKind::HardwareFailure),
+            BMC_READY,
+            ActivePolarity::ActiveHigh,
+        );
+
+        let err = mon
+            .boot_status()
+            .expect_err("expected the port error to propagate");
+        assert_eq!(err.kind(), GpioErrorKind::HardwareFailure);
+    }
+
+    /// Compile-time fence: the adapter error must satisfy `core::error::Error`
+    /// (Display + source) *and* the HAL `GpioError` (with `kind()`). Fails to
+    /// compile if either is dropped.
+    fn _assert_error_contract<E: core::error::Error + GpioError>() {}
+
+    #[test]
+    fn monitor_error_satisfies_the_full_contract() {
+        _assert_error_contract::<MonitorError<MockError>>();
+    }
+
+    #[test]
+    fn monitor_error_renders_a_human_readable_message() {
+        let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure));
+
+        assert_eq!(err.to_string(), "boot monitor error: HardwareFailure");
+    }
+
+    // The cause chain: source() carries the concrete HAL error with its full
+    // details (not just the kind), and downcasting recovers the typed error.
+    #[test]
+    fn source_exposes_the_concrete_hal_error() {
+        let err = MonitorError::from(MockError(GpioErrorKind::HardwareFailure));
+
+        let src = core::error::Error::source(&err).expect("source must carry the HAL error");
+        assert_eq!(src.to_string(), "MockError(HardwareFailure)");
+
+        let cause = src
+            .downcast_ref::<GpioCause<MockError>>()
+            .expect("source must downcast to the concrete cause");
+        assert_eq!(cause.0, MockError(GpioErrorKind::HardwareFailure));
+    }
+}
diff --git a/services/fwmanager/hal-adapters/src/lib.rs b/services/fwmanager/hal-adapters/src/lib.rs
index da8ebf3..f4eb93c 100644
--- a/services/fwmanager/hal-adapters/src/lib.rs
+++ b/services/fwmanager/hal-adapters/src/lib.rs
@@ -5,13 +5,16 @@
 //!
 //! Each type here implements a capability trait from `fwmanager-api` against
 //! a HAL-blocking trait: [`HalBootControl`] drives `BootControl` over a
-//! `ResetControl` line. Adapters live in this crate — not in the leaf
+//! `ResetControl` line, and [`GpioBootMonitor`] reads `BootMonitor` off a
+//! `GpioPort` input line. Adapters live in this crate — not in the leaf
 //! `fwmanager-api` — so that depending on a capability contract never pulls
 //! in the HAL. A transport-backed adapter belongs in its own crate depending
 //! on its own stack, by the same rule.
 
 #![cfg_attr(not(test), no_std)]
 
+mod gpio_boot_monitor;
 mod hal_boot_control;
 
+pub use gpio_boot_monitor::{GpioBootMonitor, GpioCause, MonitorError};
 pub use hal_boot_control::{BootError, HalBootControl};