Add orchestrator server runtime crate and integration QEMU test
diff --git a/services/orchestrator/server/BUILD.bazel b/services/orchestrator/server/BUILD.bazel
new file mode 100644
index 0000000..9a3cd05
--- /dev/null
+++ b/services/orchestrator/server/BUILD.bazel
@@ -0,0 +1,23 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_rust//rust:defs.bzl", "rust_library")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH")
+
+rust_library(
+    name = "orchestrator_server",
+    srcs = [
+        "src/lib.rs",
+        "src/runtime.rs",
+    ],
+    crate_name = "openprot_orchestrator_server",
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//services/orchestrator/sm:orchestrator_sm",
+        "//services/orchestrator/timer:orchestrator_timer",
+        "@pigweed//pw_kernel/userspace",
+    ],
+)
diff --git a/services/orchestrator/server/src/lib.rs b/services/orchestrator/server/src/lib.rs
new file mode 100644
index 0000000..ae559d3
--- /dev/null
+++ b/services/orchestrator/server/src/lib.rs
@@ -0,0 +1,19 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Orchestrator server: the in-process runtime that drives the pure
+//! [`openprot_orchestrator_sm`] state machine.
+//!
+//! Orchestrator-sm names timeouts as [`Event`](openprot_orchestrator_sm::Event)s
+//! but owns no clock. [`TimerManager`] lives here, in the same process, and
+//! multiplexes orchestrator-sm's boot and commit watchdogs onto the single
+//! deadline the runtime's `object_wait` already accepts — no separate timer
+//! task, no IPC on the arm/cancel path.
+
+#![no_std]
+#![forbid(unsafe_code)]
+
+pub mod runtime;
+
+pub use openprot_orchestrator_timer::{Full, TimerManager};
+pub use runtime::BootWatchdogs;
diff --git a/services/orchestrator/server/src/runtime.rs b/services/orchestrator/server/src/runtime.rs
new file mode 100644
index 0000000..e0910a3
--- /dev/null
+++ b/services/orchestrator/server/src/runtime.rs
@@ -0,0 +1,84 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Kernel-clock binding for [`TimerManager`].
+//!
+//! [`BootWatchdogs`] instantiates the host-generic [`TimerManager`] with the
+//! kernel's [`Instant`] and translates the run loop's relative boot/commit
+//! windows into the absolute deadlines the manager tracks. The absolute
+//! [`wait_deadline`](BootWatchdogs::wait_deadline) it returns is exactly the
+//! argument the loop hands to `object_wait`; after each wake the loop drains
+//! [`poll_expired`](BootWatchdogs::poll_expired) into orchestrator-sm.
+
+use openprot_orchestrator_sm::{ComponentId, Event};
+use openprot_orchestrator_timer::{Expired, Full, TimerManager};
+use userspace::time::{Clock, Duration, Instant, SystemClock};
+
+/// The orchestrator's watchdogs, driven by the kernel monotonic clock.
+///
+/// `N` bounds the boot watchdogs to the chain length, matching
+/// [`TimerManager`].
+pub struct BootWatchdogs<const N: usize> {
+    timers: TimerManager<Instant, ComponentId, N>,
+}
+
+impl<const N: usize> BootWatchdogs<N> {
+    pub const fn new() -> Self {
+        Self {
+            timers: TimerManager::new(),
+        }
+    }
+
+    /// Now plus `after`, saturating to [`Instant::MAX`] on overflow so a huge
+    /// window degrades to "wait indefinitely" rather than firing immediately.
+    fn deadline_in(after: Duration) -> Instant {
+        SystemClock::now()
+            .checked_add_duration(after)
+            .unwrap_or(Instant::MAX)
+    }
+
+    /// Arm (or re-arm) `id`'s boot watchdog to fire `after` from now. Returns
+    /// [`Full`] when a new component would exceed `N`; the run loop must
+    /// escalate rather than proceed with an unsupervised component.
+    pub fn arm_boot(&mut self, id: ComponentId, after: Duration) -> Result<(), Full> {
+        self.timers.arm_boot(id, Self::deadline_in(after))
+    }
+
+    /// Cancel `id`'s boot watchdog.
+    pub fn cancel_boot(&mut self, id: ComponentId) {
+        self.timers.cancel_boot(id);
+    }
+
+    /// Arm the commit watchdog to fire `after` from now.
+    pub fn arm_commit(&mut self, after: Duration) {
+        self.timers.arm_commit(Self::deadline_in(after));
+    }
+
+    /// Cancel the commit watchdog.
+    pub fn cancel_commit(&mut self) {
+        self.timers.cancel_commit();
+    }
+
+    /// Absolute deadline to pass to `object_wait`; [`Instant::MAX`] when nothing
+    /// is armed, so the loop blocks until a signal wakes it.
+    pub fn wait_deadline(&self) -> Instant {
+        self.timers.next_deadline().unwrap_or(Instant::MAX)
+    }
+
+    /// Pop the next watchdog due as of now, or `None`. Call in a loop after each
+    /// `object_wait` return to drain every deadline that has passed this tick.
+    pub fn poll_expired(&mut self) -> Option<Event> {
+        self.timers
+            .poll(SystemClock::now())
+            .map(|expired| match expired {
+                Expired::Boot(id) => Event::Timeout(id),
+                Expired::Commit => Event::CommitTimeout,
+            })
+    }
+}
+
+impl<const N: usize> Default for BootWatchdogs<N> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/target/ast10x0/tests/orchestrator/runtime/BUILD.bazel b/target/ast10x0/tests/orchestrator/runtime/BUILD.bazel
new file mode 100644
index 0000000..d8d6a98
--- /dev/null
+++ b/target/ast10x0/tests/orchestrator/runtime/BUILD.bazel
@@ -0,0 +1,94 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@pigweed//pw_kernel/tooling:rust_app.bzl", "rust_app")
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image", "system_image_test")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH")
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+)
+
+target_codegen(
+    name = "codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast10x0:linker_script_template",
+)
+
+rust_binary(
+    name = "target",
+    srcs = ["target.rs"],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":codegen",
+        ":linker_script",
+        "//target/ast10x0:entry",
+        "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+        "@pigweed//pw_kernel/kernel",
+        "@pigweed//pw_kernel/subsys/console:console_backend",
+        "@pigweed//pw_kernel/target:target_common",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+# Test app: drives the real orchestrator-sm core through the server runtime
+# (BootWatchdogs) — arming boot watchdogs, blocking in object_wait on
+# wait_deadline, and feeding poll_expired events back into the core.
+# debug_shutdown(Ok|Err) reports.
+rust_app(
+    name = "test_runtime",
+    srcs = ["main.rs"],
+    codegen_crate_name = "app_test_runtime",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        "//services/orchestrator/config:orchestrator_config",
+        "//services/orchestrator/server:orchestrator_server",
+        "//services/orchestrator/sm:orchestrator_sm",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+        "@rust_crates//:heapless",
+    ],
+)
+
+system_image(
+    name = "runtime",
+    apps = [":test_runtime"],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+system_image_test(
+    name = "runtime_test",
+    image = ":runtime",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+rust_binary_no_panics_test(
+    name = "no_panics_test",
+    binary = ":runtime",
+    tags = ["kernel"],
+)
diff --git a/target/ast10x0/tests/orchestrator/runtime/main.rs b/target/ast10x0/tests/orchestrator/runtime/main.rs
new file mode 100644
index 0000000..b3c4ec5
--- /dev/null
+++ b/target/ast10x0/tests/orchestrator/runtime/main.rs
@@ -0,0 +1,404 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Orchestrator integration QEMU test: all four subcomponents wired end to end
+//! under the kernel — the pure core ([`Orchestrator`], `orchestrator-sm`), the
+//! server runtime ([`BootWatchdogs`], `orchestrator-server`) which wraps the
+//! watchdog keeper (`orchestrator-timer`), and the board device table
+//! ([`DeviceConfig`], `orchestrator-config`).
+//!
+//! The runtime owns the clock and the mapping, so the shell stays thin:
+//!   - boot windows come from the device table ([`BootCheckpoint::timeout`]);
+//!     the shell only converts `core::time::Duration` to the kernel's
+//!     [`Duration`] at the arm site.
+//!   - [`BootWatchdogs::arm_boot`] takes that *relative* window; the runtime
+//!     computes the absolute deadline.
+//!   - [`BootWatchdogs::wait_deadline`] is handed straight to `object_wait`.
+//!   - [`BootWatchdogs::poll_expired`] yields the `Event`s the core consumes —
+//!     no mapping in the shell.
+//!
+//! Coverage: the *inner checkpoint walk* (`bl1` → `kernel`, re-armed through the
+//! runtime) for a single component, the *outer component walk* across a
+//! multi-component chain (nearest-of-many deadlines, correct-id recovery), and
+//! the commit watchdog. The interrupt object (IRQ 44, self-fired) stands in for
+//! a component reaching a checkpoint.
+
+#![no_main]
+#![no_std]
+
+use app_test_runtime::{constants, handle, signals};
+use openprot_orchestrator_server::BootWatchdogs;
+use openprot_orchestrator_sm::{
+    Chain, ComponentAttrs, ComponentId, Effect, EffectError, Event, Orchestrator, Platform,
+    PowerOnResult, State,
+};
+use orchestrator_config::{BootCheckpoint, DeviceConfig};
+use pw_status::{Error, Result};
+use userspace::time::Duration;
+use userspace::{entry, syscall};
+
+/// The components this test supervises.
+const C0: ComponentId = ComponentId::new(0);
+const C1: ComponentId = ComponentId::new(1);
+
+/// Chain capacity and effect-sink cap for the core (`E >= 2*N + 2`).
+const N: usize = 4;
+const E: usize = 2 * N + 2;
+const MAX_RETRY: u8 = 3;
+
+/// Commit watchdog window. Not a boot window, so it stays a local constant
+/// rather than coming from the device table.
+const COMMIT_WINDOW: Duration = Duration::from_millis(50);
+
+type Core = Orchestrator<N, E>;
+type Watchdogs = BootWatchdogs<N>;
+
+/// The device table: per-checkpoint windows, exactly as a board would declare
+/// them. Two checkpoints so the inner walk exercises re-arm-on-progress
+/// (`bl1` then `kernel`).
+const SOC: DeviceConfig<u8, u8> = DeviceConfig::new(
+    "soc",
+    0,
+    &[
+        BootCheckpoint::new("bl1", 0, core::time::Duration::from_millis(50)),
+        BootCheckpoint::new("kernel", 0, core::time::Duration::from_millis(50)),
+    ],
+);
+
+/// The device table speaks `core::time::Duration`; the runtime speaks the
+/// kernel's [`Duration`]. Converting is the shell's job.
+fn window(timeout: core::time::Duration) -> Duration {
+    Duration::from_millis(timeout.as_millis() as u64)
+}
+
+/// A fake [`Platform`] for the run loop. It records the `ReleaseReset(id)`
+/// effects that open each component's boot supervision; every other effect is
+/// accepted so the core can settle.
+struct FakePlatform {
+    released: heapless::Vec<ComponentId, N>,
+}
+
+impl FakePlatform {
+    const fn new() -> Self {
+        Self {
+            released: heapless::Vec::new(),
+        }
+    }
+
+    fn was_released(&self, id: ComponentId) -> bool {
+        self.released.contains(&id)
+    }
+}
+
+impl Platform for FakePlatform {
+    fn execute(&mut self, effect: Effect) -> core::result::Result<(), EffectError> {
+        if let Effect::ReleaseReset(id) = effect {
+            let _ = self.released.push(id);
+        }
+        Ok(())
+    }
+}
+
+/// A fresh core with the given components, each passive/required.
+fn new_core(ids: &[ComponentId]) -> Result<Core> {
+    let mut v = heapless::Vec::<(ComponentId, ComponentAttrs), N>::new();
+    for id in ids {
+        v.push((*id, ComponentAttrs::passive_required()))
+            .map_err(|_| Error::ResourceExhausted)?;
+    }
+    let chain: Chain<N> = v.try_into().map_err(|_| Error::Internal)?;
+    Ok(Orchestrator::new(chain, MAX_RETRY))
+}
+
+/// Power on, then pass verification for each component in chain order. Each
+/// `VerificationPassed` releases its component (speculative release), so all
+/// are released before any boots.
+fn drive_releases(core: &mut Core, plat: &mut FakePlatform, ids: &[ComponentId]) -> Result<()> {
+    core.dispatch(plat, Event::PowerGood(PowerOnResult::Provisioned));
+    for id in ids {
+        core.dispatch(plat, Event::VerificationPassed(*id));
+        if !plat.was_released(*id) {
+            return Err(Error::Internal);
+        }
+    }
+    Ok(())
+}
+
+/// Run one component's inner checkpoint walk through the runtime and return its
+/// single terminal event. `reached` simulates the device: it fires its progress
+/// signal for the first `reached` checkpoints, then goes quiet — so
+/// `reached == len` boots, anything less times out at checkpoint `reached`.
+fn checkpoint_walk(
+    wd: &mut Watchdogs,
+    id: ComponentId,
+    checkpoints: &[BootCheckpoint<u8>],
+    reached: usize,
+) -> Result<Event> {
+    let mut k = 0usize;
+    wd.arm_boot(id, window(checkpoints[k].timeout()))
+        .map_err(|_| Error::ResourceExhausted)?;
+    loop {
+        // Simulated device reaching checkpoint `k`: latch its progress signal
+        // before the wait (interrupt objects hold it pending, so no race).
+        if k < reached {
+            syscall::debug_trigger_interrupt(constants::BOOT_PROGRESS)?;
+        }
+
+        let deadline = wd.wait_deadline();
+        match syscall::object_wait(handle::BOOT_SIGNAL, signals::BOOT_PROGRESS, deadline) {
+            Ok(wait) => {
+                if !wait.pending_signals.contains(signals::BOOT_PROGRESS) {
+                    return Err(Error::Internal);
+                }
+                syscall::interrupt_ack(handle::BOOT_SIGNAL, signals::BOOT_PROGRESS)?;
+                k += 1;
+                if k == checkpoints.len() {
+                    wd.cancel_boot(id);
+                    return Ok(Event::Booted(id));
+                }
+                // Forward progress: re-arm the next checkpoint through the runtime.
+                wd.arm_boot(id, window(checkpoints[k].timeout()))
+                    .map_err(|_| Error::ResourceExhausted)?;
+            }
+            Err(Error::DeadlineExceeded) => {
+                // The window lapsed: the runtime already mapped it to an `Event`.
+                return wd.poll_expired().ok_or(Error::Internal);
+            }
+            Err(e) => return Err(e),
+        }
+    }
+}
+
+/// Simulate `id`'s device reporting in: latch the progress signal, wait, ack,
+/// and retire its watchdog through the runtime.
+fn confirm(wd: &mut Watchdogs, id: ComponentId) -> Result<()> {
+    syscall::debug_trigger_interrupt(constants::BOOT_PROGRESS)?;
+    match syscall::object_wait(
+        handle::BOOT_SIGNAL,
+        signals::BOOT_PROGRESS,
+        wd.wait_deadline(),
+    ) {
+        Ok(wait) => {
+            if !wait.pending_signals.contains(signals::BOOT_PROGRESS) {
+                return Err(Error::Internal);
+            }
+            syscall::interrupt_ack(handle::BOOT_SIGNAL, signals::BOOT_PROGRESS)?;
+            wd.cancel_boot(id);
+            Ok(())
+        }
+        Err(e) => Err(e),
+    }
+}
+
+/// Inner walk, happy path: a single component passes every checkpoint (windows
+/// from the device table, re-armed through the runtime), the walk yields
+/// `Booted`, and the core stays `Ready`. A late `Timeout` is then a no-op — the
+/// watchdog was retired by the confirmation.
+fn scenario_checkpoint_confirmed() -> Result<()> {
+    pw_log::info!("scenario 1: checkpoint walk confirmed");
+    let mut core = new_core(&[C0])?;
+    let mut plat = FakePlatform::new();
+    let mut wd = Watchdogs::new();
+
+    drive_releases(&mut core, &mut plat, &[C0])?;
+    if core.state() != State::Ready {
+        pw_log::error!("scenario 1: single component did not reach Ready on release");
+        return Err(Error::Internal);
+    }
+
+    let terminal = checkpoint_walk(&mut wd, C0, SOC.checkpoints(), SOC.checkpoints().len())?;
+    if terminal != Event::Booted(C0) {
+        pw_log::error!("scenario 1: walk did not confirm boot");
+        return Err(Error::Internal);
+    }
+    core.dispatch(&mut plat, terminal);
+    if core.state() != State::Ready {
+        pw_log::error!("scenario 1: core left Ready after boot confirmed");
+        return Err(Error::Internal);
+    }
+
+    // The watchdog is retired: a stale timeout must not re-open recovery.
+    core.dispatch(&mut plat, Event::Timeout(C0));
+    if core.state() != State::Ready {
+        pw_log::error!("scenario 1: stale timeout re-opened recovery");
+        return Err(Error::Internal);
+    }
+
+    pw_log::info!("scenario 1: PASS");
+    Ok(())
+}
+
+/// Inner walk, timeout path: the device never signals, the first checkpoint's
+/// window lapses, the runtime surfaces `Timeout`, and the core recovers.
+fn scenario_checkpoint_timeout() -> Result<()> {
+    pw_log::info!("scenario 2: checkpoint walk timeout drives recovery");
+    let mut core = new_core(&[C0])?;
+    let mut plat = FakePlatform::new();
+    let mut wd = Watchdogs::new();
+
+    drive_releases(&mut core, &mut plat, &[C0])?;
+
+    let terminal = checkpoint_walk(&mut wd, C0, SOC.checkpoints(), 0)?;
+    if terminal != Event::Timeout(C0) {
+        pw_log::error!("scenario 2: walk did not time out");
+        return Err(Error::Internal);
+    }
+    core.dispatch(&mut plat, terminal);
+    if core.state() != State::Recovering(C0) {
+        pw_log::error!("scenario 2: core did not enter recovery");
+        return Err(Error::Internal);
+    }
+
+    pw_log::info!("scenario 2: PASS");
+    Ok(())
+}
+
+/// Outer walk, all confirm: a two-component chain, both boot watchdogs armed at
+/// once, both components report in, the core reaches `Ready`.
+fn scenario_chain_all_confirm() -> Result<()> {
+    pw_log::info!("scenario 3: multi-component chain all confirm");
+    let mut core = new_core(&[C0, C1])?;
+    let mut plat = FakePlatform::new();
+    let mut wd = Watchdogs::new();
+
+    drive_releases(&mut core, &mut plat, &[C0, C1])?;
+    if core.state() != State::Ready {
+        pw_log::error!("scenario 3: chain did not reach Ready on release");
+        return Err(Error::Internal);
+    }
+
+    // Both released speculatively: arm both watchdogs before either reports.
+    let boot = window(SOC.checkpoints()[0].timeout());
+    wd.arm_boot(C0, boot)
+        .map_err(|_| Error::ResourceExhausted)?;
+    wd.arm_boot(C1, boot)
+        .map_err(|_| Error::ResourceExhausted)?;
+
+    confirm(&mut wd, C0)?;
+    core.dispatch(&mut plat, Event::Booted(C0));
+    confirm(&mut wd, C1)?;
+    core.dispatch(&mut plat, Event::Booted(C1));
+
+    if core.state() != State::Ready {
+        pw_log::error!("scenario 3: core left Ready after both booted");
+        return Err(Error::Internal);
+    }
+
+    pw_log::info!("scenario 3: PASS");
+    Ok(())
+}
+
+/// Outer walk, one lapses: both watchdogs armed, `C0` reports in, `C1` goes
+/// quiet. With only `C1` left, the runtime's nearest deadline is `C1`'s; it
+/// lapses and `poll_expired` surfaces `Timeout(C1)`, recovering the right one.
+fn scenario_chain_one_timeout() -> Result<()> {
+    pw_log::info!("scenario 4: multi-component chain, one times out");
+    let mut core = new_core(&[C0, C1])?;
+    let mut plat = FakePlatform::new();
+    let mut wd = Watchdogs::new();
+
+    drive_releases(&mut core, &mut plat, &[C0, C1])?;
+
+    let boot = window(SOC.checkpoints()[0].timeout());
+    wd.arm_boot(C0, boot)
+        .map_err(|_| Error::ResourceExhausted)?;
+    wd.arm_boot(C1, boot)
+        .map_err(|_| Error::ResourceExhausted)?;
+
+    confirm(&mut wd, C0)?;
+    core.dispatch(&mut plat, Event::Booted(C0));
+
+    // Only C1 remains armed; wait for its window to lapse.
+    match syscall::object_wait(
+        handle::BOOT_SIGNAL,
+        signals::BOOT_PROGRESS,
+        wd.wait_deadline(),
+    ) {
+        Ok(_) => {
+            pw_log::error!("scenario 4: unexpected signal, C1's device is quiet");
+            return Err(Error::Internal);
+        }
+        Err(Error::DeadlineExceeded) => {
+            let event = wd.poll_expired().ok_or(Error::Internal)?;
+            if event != Event::Timeout(C1) {
+                pw_log::error!("scenario 4: runtime timed out the wrong component");
+                return Err(Error::Internal);
+            }
+            core.dispatch(&mut plat, event);
+        }
+        Err(e) => return Err(e),
+    }
+
+    if core.state() != State::Recovering(C1) {
+        pw_log::error!("scenario 4: core did not recover C1");
+        return Err(Error::Internal);
+    }
+
+    pw_log::info!("scenario 4: PASS");
+    Ok(())
+}
+
+/// Commit path of the runtime binding: arm the commit watchdog, let it lapse
+/// against the real clock, and confirm the runtime surfaces `CommitTimeout`
+/// (and nothing more).
+fn scenario_commit_timeout() -> Result<()> {
+    pw_log::info!("scenario 5: commit watchdog surfaces CommitTimeout");
+    let mut wd = Watchdogs::new();
+
+    wd.arm_commit(COMMIT_WINDOW);
+    match syscall::object_wait(
+        handle::BOOT_SIGNAL,
+        signals::BOOT_PROGRESS,
+        wd.wait_deadline(),
+    ) {
+        Ok(_) => {
+            pw_log::error!("scenario 5: unexpected signal, no device is armed");
+            return Err(Error::Internal);
+        }
+        Err(Error::DeadlineExceeded) => {
+            if wd.poll_expired() != Some(Event::CommitTimeout) {
+                pw_log::error!("scenario 5: runtime did not surface CommitTimeout");
+                return Err(Error::Internal);
+            }
+        }
+        Err(e) => return Err(e),
+    }
+
+    // One-shot: the watchdog is drained, nothing more is due.
+    if wd.poll_expired().is_some() {
+        pw_log::error!("scenario 5: commit watchdog fired twice");
+        return Err(Error::Internal);
+    }
+
+    pw_log::info!("scenario 5: PASS");
+    Ok(())
+}
+
+fn run_test() -> Result<()> {
+    scenario_checkpoint_confirmed()?;
+    scenario_checkpoint_timeout()?;
+    scenario_chain_all_confirm()?;
+    scenario_chain_one_timeout()?;
+    scenario_commit_timeout()?;
+    Ok(())
+}
+
+#[entry]
+fn entry() {
+    match run_test() {
+        Ok(()) => {
+            pw_log::info!("runtime integration test: all scenarios PASSED");
+            let _ = syscall::debug_shutdown(Ok(()));
+        }
+        Err(e) => {
+            pw_log::error!("runtime integration test FAILED: {}", e as u32);
+            let _ = syscall::debug_shutdown(Err(e));
+        }
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/orchestrator/runtime/system.json5 b/target/ast10x0/tests/orchestrator/runtime/system.json5
new file mode 100644
index 0000000..002c5c2
--- /dev/null
+++ b/target/ast10x0/tests/orchestrator/runtime/system.json5
@@ -0,0 +1,66 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 runtime integration QEMU test: the orchestrator server runtime
+// (BootWatchdogs) driving the pure core through an object_wait loop.
+//
+// Single app, single process. The interrupt object (IRQ 44, self-fired via
+// debug_trigger_interrupt) stands in for a component's boot-progress signal;
+// deadlines come from BootWatchdogs::wait_deadline and expiries from
+// BootWatchdogs::poll_expired.
+//
+// Memory map (AST10x0: 768 KB SRAM, no XIP), same shape as tests/orchestrator/boot_walk:
+//   0x00000000 - 0x00000500  vector table (1280 B)
+//   0x00000500 - 0x00020200  kernel flash (~127 KB)
+//   then app flash (128 KB)
+//   0x00060000 - 0x00080000  kernel RAM (128 KB)
+//   then app RAM (32 KB)
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1280,
+    },
+    kernel: {
+        flash_start_address: 0x00000500,
+        flash_size_bytes: 129792,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "test_runtime",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "test_runtime_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "boot_signal",
+                            type: "interrupt",
+                            irqs: [
+                                {
+                                    name: "boot_progress",
+                                    number: 44,
+                                },
+                            ],
+                        },
+                        {
+                            type: "thread",
+                            name: "test_runtime_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },],
+
+                },
+            ],
+            constants: [
+                {
+                    name: "boot_progress",
+                    type: "u32",
+                    value: 44,
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/orchestrator/runtime/target.rs b/target/ast10x0/tests/orchestrator/runtime/target.rs
new file mode 100644
index 0000000..961db53
--- /dev/null
+++ b/target/ast10x0/tests/orchestrator/runtime/target.rs
@@ -0,0 +1,35 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+#![no_std]
+#![no_main]
+
+use console_backend::console_backend_write_all;
+use entry as _;
+use target_common::{declare_target, TargetInterface};
+
+pub struct Target {}
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 Runtime Integration Test";
+
+    fn main() -> ! {
+        codegen::start();
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+
+    fn shutdown(code: u32) -> ! {
+        pw_log::info!("Shutting down with code {}", code as u32);
+        let sentinel: &[u8] = if code == 0 {
+            b"TEST_RESULT:PASS\n"
+        } else {
+            b"TEST_RESULT:FAIL\n"
+        };
+        let _ = console_backend_write_all(sentinel);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);