diff --git a/docs/src/design/orchestrator/orchestrator-machine.md b/docs/src/design/orchestrator/orchestrator-machine.md
index b7227f8a..69c9dfad 100644
--- a/docs/src/design/orchestrator/orchestrator-machine.md
+++ b/docs/src/design/orchestrator/orchestrator-machine.md
@@ -35,6 +35,8 @@ stateDiagram-v2
Recovering --> PreSupervision : Restored [retry < max_retry] (re-verify)
Recovering --> PreSupervision : Restored [retry ≥ max_retry, Isolable/Cascading] / AssertReset (skip — held)
Recovering --> Locked : Restored [retry ≥ max_retry, Required] (self-emits RecoveryFailed) / LatchLockdown
+ Recovering --> PreSupervision : RecoveryUnavailable [Isolable/Cascading] / AssertReset (skip — held)
+ Recovering --> Locked : RecoveryUnavailable [Required] (self-emits RecoveryFailed) / LatchLockdown
Locked --> Locked : (terminal — all events ignored)
```
@@ -232,11 +234,26 @@ entire region is affected (not the whole chain — INV5).
| `Restored(id)` | `id == failed`, cap reached, `Isolable` | `AssertReset(failed)` · `ReportIsolated(failed)` | `PreSupervision` (recovery exhausted: mark `failed` `Isolated`, reset its `retry`; the re-walk skips it) |
| `Restored(id)` | `id == failed`, cap reached, `Cascading` | `AssertReset` · `ReportIsolated` (per component) | `PreSupervision` (recovery exhausted: mark `failed` + `depends_on` dependents `Isolated`) |
| `Restored(id)` | `id == failed`, cap reached, `Required` | `ReportRecoveryFailed(failed)` · `Effect::Emit(RecoveryFailed)` | `Handled` (orchestrator queues `RecoveryFailed` next — INV7) |
+| `RecoveryUnavailable(id)` | `id != failed` | — | `Handled` (stale — belongs to a displaced episode) |
+| `RecoveryUnavailable(id)` | `id == failed`, `Isolable` | `AssertReset(failed)` · `ReportIsolated(failed)` | `PreSupervision` (exhausted immediately, bypassing the retry cap) |
+| `RecoveryUnavailable(id)` | `id == failed`, `Cascading` | `AssertReset` · `ReportIsolated` (per component) | `PreSupervision` (exhausted immediately) |
+| `RecoveryUnavailable(id)` | `id == failed`, `Required` | `ReportRecoveryFailed(failed)` · `Effect::Emit(RecoveryFailed)` | `Handled` |
| `RecoveryFailed` | — | — | `Locked` |
| anything else | — | — | `Outcome::Super` → `SupervisingPlatform` |
("cap reached" = `retry + 1 >= max_retry`.)
+**`RecoveryUnavailable` is the platform's own exhaustion signal.** Where
+`Restored` past the retry cap means "the machine gave up after counting failed
+restores," `RecoveryUnavailable` means "the platform driver reports no
+recovery source left for this component" — reported in place of `Restored`
+when it has no untried image/slot to swap in. It is authoritative: it runs
+the same exhaustion handling as the retry cap (`Isolable`/`Cascading` gate and
+skip, `Required` locks) immediately, without bumping or waiting on `retry`.
+This keeps `max_retry` a pure liveness backstop against a restore that keeps
+"succeeding" without ever re-verifying clean, rather than the only way
+recovery ever ends.
+
**Two-stage recovery (CSA-aligned).** A verification failure never skips a
component outright. Every failure — during initial boot or a re-walk — first
brings the machine here, to `Recovering`, which restores the failed component's
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs
index 608cb60f..e049c816 100644
--- a/services/orchestrator/sm/src/lib.rs
+++ b/services/orchestrator/sm/src/lib.rs
@@ -440,6 +440,28 @@ impl Rot {
}
}
+ /// Recovery is over for `failed` without success: gate per policy
+ /// (`Isolable`/`Cascading` skip; `Required` reports + latches `Locked`).
+ /// Shared by the retry-cap path (`Restored`, count exhausted) and the
+ /// platform's `RecoveryUnavailable` path, so the two can never diverge.
+ fn exhaust_recovery(&mut self, ctx: &mut Sink, failed: ComponentId) -> Outcome {
+ match self.gate_by_policy(ctx, failed) {
+ Gating::Gated => {
+ self.clear_retry(failed);
+ Outcome::Transition(State::PreSupervision)
+ }
+ // `Required`, or an unknown/missing id: report the component that
+ // forced the halt, then lock down. The report precedes the
+ // internal `Emit`, so it is actuated before the machine moves
+ // toward `Locked`.
+ Gating::NotGated => {
+ ctx.emit(Effect::ReportRecoveryFailed(failed));
+ ctx.emit(Effect::Emit(Event::RecoveryFailed));
+ Outcome::Handled
+ }
+ }
+ }
+
/// Shared `CorruptionDetected` handling. Delegates the policy interpretation
/// to [`gate_by_policy`](Self::gate_by_policy) so this path and the
/// recovery-exhaustion path can never diverge:
@@ -774,26 +796,20 @@ impl Rot {
if attempts < self.max_retry {
Outcome::Transition(State::PreSupervision)
} else {
- // Retries exhausted: gate via the same `gate_by_policy`
- // the runtime-corruption path uses, so the two can never
- // disagree. Gated → continue the walk; NotGated
- // (Required/unknown) → lock down.
- match self.gate_by_policy(ctx, failed) {
- Gating::Gated => {
- self.clear_retry(failed);
- Outcome::Transition(State::PreSupervision)
- }
- // `Required`, or an unknown/missing id: report the
- // component that forced the halt, then lock down.
- // The report precedes the internal `Emit`, so it is
- // actuated before the machine moves toward `Locked`.
- Gating::NotGated => {
- ctx.emit(Effect::ReportRecoveryFailed(failed));
- ctx.emit(Effect::Emit(Event::RecoveryFailed));
- Outcome::Handled
- }
- }
+ // Retries exhausted: gate via the same shared arm the
+ // platform's `RecoveryUnavailable` path uses, so the
+ // two can never disagree.
+ self.exhaust_recovery(ctx, failed)
+ }
+ }
+ Event::RecoveryUnavailable(id) => {
+ if *id != failed {
+ return Outcome::Handled; // same guard as Restored
}
+ // Authoritative: the platform is out of sources, so the
+ // machine does not wait for the retry count to run out
+ // (does not call `bump_retry`).
+ self.exhaust_recovery(ctx, failed)
}
Event::RecoveryFailed => Outcome::Transition(State::Locked),
_ => Outcome::Super,
@@ -992,6 +1008,16 @@ pub struct EffectError;
/// of the escalation ladder — the core has nothing stronger to emit and
/// will *believe* it is `Locked`. The driver must treat that failure as
/// terminal (halt/reset), not a recoverable error.
+/// - **[`Effect::RecoverComponent`] reports its verdict as an event, not an
+/// `execute` error.** On success the driver feeds back
+/// [`Event::Restored`]; when its configured recovery sources for that
+/// component are exhausted, it feeds back [`Event::RecoveryUnavailable`]
+/// instead — never [`EffectError`]. `EffectError` from a `RecoverComponent`
+/// call is reserved for a genuine actuation fault (e.g. a bus error during
+/// the image swap), which fails closed to [`State::Locked`] unconditionally.
+/// Reporting "out of images" that way would lock the whole platform down
+/// even for an `Isolable`/`Cascading` component, instead of letting it be
+/// gated per [`FailurePolicy`] like the count-driven exhaustion path.
pub trait Platform {
fn execute(&mut self, effect: Effect) -> Result