Skip to content

Orchestrator spec and reference impl - #357

Merged
rusty1968 merged 44 commits into
OpenPRoT:mainfrom
rusty1968:orchestrator-sm
Aug 5, 2026
Merged

Orchestrator spec and reference impl#357
rusty1968 merged 44 commits into
OpenPRoT:mainfrom
rusty1968:orchestrator-sm

Conversation

@rusty1968

Copy link
Copy Markdown
Collaborator

No description provided.

Comment thread docs/src/design/orchestrator/orchestrator-machine.md Outdated
@leongross

Copy link
Copy Markdown
Member

Do you plan to realize your state machine implementation based on https://github.com/typeconstructor/rot-reducer?

@chrysh

chrysh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@rusty1968 Should we also add multi-level cascades, concurrent faults, and double-fault recovery tests? Or is this not necessary?

Answer

All three are already covered: multi-level cascade (cascading_runtime_corruption_cascades_transitively), concurrent fault (corruption_while_recovering_retargets_to_new_component, corruption_during_update_discards_staged), and double fault (retry_cap_self_latches_via_emit). Happy to add a specific scenario these miss.

@chrysh

chrysh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Are we trying to restore the golden image several times or just once?

Answer

The state machine isn't aware of the source — note the effect is named RecoverComponent, not "restore golden image." Golden vs A/B vs last-known-good is the platform driver's call. The reducer only decides how many attempts: up to max_retry per component (set 1 for once).

Comment thread services/orchestrator/sm/src/lib.rs
Comment thread services/orchestrator/sm/src/lib.rs
Comment thread services/orchestrator/sm/src/lib.rs Outdated
Comment thread services/orchestrator/sm/src/lib.rs Outdated
Comment thread services/orchestrator/sm/src/lib.rs Outdated
Comment thread docs/src/design/orchestrator/orchestrator-machine.md Outdated
Comment thread services/orchestrator/sm/src/lib.rs Outdated
@chrysh

chrysh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Even if we only ever update one device at a time, §5.3.3 background polling can report corruption of device B while device A is mid-update or mid-recovery. What does the machine do then?

@chrysh

chrysh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Maybe each device should have their own state machine?

@rusty1968

Copy link
Copy Markdown
Collaborator Author

Even if we only ever update one device at a time, §5.3.3 background polling can report corruption of device B while device A is mid-update or mid-recovery. What does the machine do then?

Excellent question - The scenario is covered by tests:

  • corruption_in_updating_triggers_recovery / corruption_during_update_discards_staged — if a device is found corrupt while another device is being updated, the update is dropped and the machine switches to fixing the corrupt device, throwing away the half-finished update image.

  • corruption_while_recovering_retargets_to_new_component / restored_for_wrong_component_does_not_advance_recovery — if a second device fails while we're still fixing the first, we switch to fixing the second; and if the first one later says "I'm fixed," we ignore it, since we'd already moved on.

  • cascading_runtime_corruption_cascades_transitively — when the failed device is one we're allowed to simply switch off (rather than one we must stop everything to fix), we isolate it and everything that depends on it, and keep going instead of interrupting the current work.

@rusty1968

Copy link
Copy Markdown
Collaborator Author

Maybe each device should have their own state machine?

Your intuition is directionally correct — but what genuinely matters here is per-device state , and the design already carries it (ComponentStatus, retry_budget, isolation markers,etc). What stays singular is the ordering: we only fix one device at a time, and we only update one device at a time. The machine keeps a single "who am I fixing right now" slot (State::Recovering(id)), so it can't be pointed at two devices at once. That one-at-a-time behavior is on purpose, not a limitation we ran into.

@rusty1968

This comment was marked as resolved.

@chrysh

chrysh commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Maybe each device should have their own state machine?

Your intuition is directionally correct — but what genuinely matters here is per-device state , and the design already carries it (ComponentStatus, retry_budget, isolation markers,etc). What stays singular is the ordering: we only fix one device at a time, and we only update one device at a time. The machine keeps a single "who am I fixing right now" slot (State::Recovering(id)), so it can't be pointed at two devices at once. That one-at-a-time behavior is on purpose, not a limitation we ran into.

What happens if we get another update firmware request for device B while we are fixing the firmware if device A?

@rusty1968

rusty1968 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Maybe each device should have their own state machine?

Your intuition is directionally correct — but what genuinely matters here is per-device state , and the design already carries it (ComponentStatus, retry_budget, isolation markers,etc). What stays singular is the ordering: we only fix one device at a time, and we only update one device at a time. The machine keeps a single "who am I fixing right now" slot (State::Recovering(id)), so it can't be pointed at two devices at once. That one-at-a-time behavior is on purpose, not a limitation we ran into.

What happens if we get another update firmware request for device B while we are fixing the firmware if device A?

TL;DR

The UpdateRequest is silently dropped. Recovery is not interrupted, no effect is emitted, and nothing is queued. The requester gets no response at all.

That drop is deliberate as a policy (recovery outranks update — "fix first, then update"), but the silence is the part worth a second look: the requester cannot tell "refused, busy recovering" apart from "lost."

CSA Alignment

The CSA mandates that failures be reported and models updates as answered requests, which is exactly the gap I am fixing with 57fedb0

This is a sketch of how we use the report effect.

impl Platform for PlatformAst  {
    fn execute(&mut self, effect: Effect) -> Result<(), EffectError> {
        match effect {
            // ... device I/O effects (ReadFirmware, ReleaseReset, ...) ...
            Effect::ReportIsolated(id)       => self.mgmt.report_isolated(id),
            Effect::ReportRecoveryFailed(id) => self.mgmt.report_recovery_failed(id),
            // Pure protocol reply — no device I/O. Answer the pending PLDM
            // FW-update request so the requester gets a definite "busy, retry".
            Effect::ReportUpdateDeferred =>
                self.pldm.respond_update(PldmCompletion::RetryLater),
            // ...
        }
    }
}

The CSA does not mandate update-vs-recovery arbitration. Recovery is described as autonomous and separate from update; the interaction of an in-flight recovery with an incoming update is left to the implementation. Our single-flight, recovery-priority choice is therefore a legitimate implementation-defined policy, neither required nor forbidden by the CSA.

rusty1968 and others added 16 commits July 29, 2026 13:58
Add two new documents — a narrative walkthrough of the orchestrator
state machine (orchestrator-sm-walkthru.md) and a reference transitions
table (orchestrator-sm-transitions.md) — and expand the existing state
machine document with superstate handler descriptions. Register both new
pages in SUMMARY.md.
PreSupervision isn't linked into SupervisingPlatform, so corruption on an
already-released component is dropped during an all-Passive chain walk.
CSA defines no mechanism for detecting corruption of a live, executing
component, so this isn't a confirmed requirement to fix.

Reframes the Superstate doc comment, renames the expected-failing test
(...triggers_recovery -> ...is_dropped) to match actual behavior, and
softens the unconfirmed CSA-continuity claim in the walkthrough doc.
Replace hardcoded EFFECT_CAP=8 with a per-machine capacity E on Rot/
Orchestrator/Sink, floored at compile time to E >= N+2 (worst case: a
full cascade of N AssertResets plus the PreSupervision entry's two
effects land in one Sink). emit now panics on overflow instead of
silently dropping security-critical effects.
Keep gated set across Ready (finding #2); share gate_by_policy between
corruption and exhaustion paths so Cascading cascades in both (finding #4).
Add regression tests.
Replace the single global retry_count with a per-component retries map so
interleaved recoveries don't share a budget. Clear a component's streak on
successful verification and on gating; clear all on Ready.
Extract the unit test module into src/tests.rs via mod tests; and add it to
the library srcs. No behavior change; lib.rs drops to ~800 lines.
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.
rusty1968 added 11 commits July 29, 2026 13:58
Boot-progress watchdog timeouts now enter recovery for the awaited component; stale/spurious timeouts are dropped (INV9). Adds three tests.
…Restored

A Required corruption that preempts an in-flight update now emits DiscardStaged so the staged image is not orphaned. Recovering ignores a Restored whose id is not the current recovery target, preventing a displaced episode from mis-crediting the wrong component. Adds two tests.
Replace the gated set and retries map with a single statuses array
parallel to chain, each entry a ComponentLifecycle (Nominal/Isolated)
plus a retry count. Add status_index/gate_one helpers; rewrite is_gated,
bump_retry, clear_retry, gate_by_policy, cascade_hold, and the Ready
entry action to index into statuses. State enum and behavior unchanged.
Add BootConfirmed event + CommitSvnFloor effect; Ready emits the commit
only on a proven-healthy boot, decoupling the anti-rollback floor advance
from ActivateUpdate. Conforms to CSA resiliency: the floor advances after
a higher-SVN image successfully boots and is deemed stable.
Track boot-progress per component instead of watching only Active
devices. Passive components released from reset are now watched for
liveness (new Booted event) under the same watchdog as Active
ComponentReady; a released component that misses its window is recovered
from any state. Timeout is handled uniformly in the supervisor and
PreSupervision; the AwaitingReady-only Timeout arm is removed.
@rusty1968
rusty1968 marked this pull request as ready for review July 29, 2026 21:35
@chrysh

chrysh commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@rusty1968 Should we add those test?:

/// INV9: the iRoT gate must hold even when the eRoT walk finishes first.
/// C0's `ComponentReady` is still outstanding when the last verdict
/// arrives; completing the walk must not bypass the gate.
#[test]
fn chain_exhaustion_does_not_bypass_irot_gate() {
    let (_effects, state) = drive(
        chain(&[
            (C0, ComponentAttrs::active_required()),
            (C1, ComponentAttrs::passive_required()),
        ]),
        &[
            BOOT,
            Event::VerificationPassed(C0), // C0 released; its iRoT still owes ComponentReady
            Event::VerificationPassed(C1), // walk done — but C0 never reported
        ],
    );
    assert_eq!(state, State::AwaitingReady(Some(C0)));
}
/// Trust invariant: a verdict only means something for the component under
/// verification. A replayed `VerificationPassed` for a component that has
/// since been isolated must not re-release it.
#[test]
fn replayed_verdict_does_not_release_isolated_component() {
    let (effects, state) = drive(
        chain(&[
            (C0, ComponentAttrs::passive_isolable()),
            (C1, ComponentAttrs::passive_required()),
        ]),
        &[
            BOOT,
            Event::VerificationPassed(C0), // released; walk moves to C1
            Event::CorruptionDetected(C0), // Isolable → AssertReset + Isolated
            Event::VerificationPassed(C0), // replayed / spurious verdict
        ],
    );
    let releases_c0 = effects
        .iter()
        .filter(|&&e| e == Effect::ReleaseReset(C0))
        .count();
    assert_eq!(releases_c0, 1, "isolated component was re-released");
    assert_ne!(state, State::Ready, "C1's verdict is still outstanding");
}
/// A healthy Active component must not be dragged into recovery by a stale
/// boot-progress watchdog. After a re-walk, C0's `ComponentReady` arrives
/// while the machine is back in `PreSupervision`; the report must clear its
/// watchdog (as `Booted` does there), making the later `Timeout` stale.
#[test]
fn component_ready_during_rewalk_clears_watchdog() {
    let (effects, state) = drive(
        chain(&[
            (C0, ComponentAttrs::active_required()),
            (C1, ComponentAttrs::passive_required()),
        ]),
        &[
            BOOT,
            Event::VerificationPassed(C0), // C0 released, watchdog armed
            Event::VerificationFailed(C1), // C1 fails → Recovering(C1)
            Event::Restored(C1),           // restore ok → re-walk from top
            Event::ComponentReady(C0),     // C0's iRoT reports in — discarded
            Event::Timeout(C0),            // stale watchdog fires
        ],
    );
    assert!(
        !effects.contains(&Effect::RestoreGoldenImage(C0)),
        "healthy, ready C0 was sent to recovery by a stale watchdog"
    );
    assert_ne!(state, State::Recovering(C0));
}

Answers

1. chain_exhaustion_does_not_bypass_irot_gate

Answer: We don't block in AwaitingReady; a finished walk goes straight to Ready, and a released active that never reports is caught by the boot watchdog, not by waiting.

**Covered by: ** single_active_chain_goes_directly_to_ready, timeout_awaited_enters_recovering

2. replayed_verdict_does_not_release_isolated_component

Answer: A gated component is never re-released: only the component at the cursor can be released, and a replayed verdict for an isolated one is dropped, so ReleaseReset fires once.

**Already Covered by: ** isolable_runtime_corruption_holds_across_rewalk, property_verify_before_release_holds_under_random_sequences

3. component_ready_during_rewalk_clears_watchdog

Answer: A re-walk clears each live component's watchdog via quiesce_all on entry, so a stale timeout afterward is dropped and a healthy component is never dragged into recovery.

**Already Covered by: ** passive_booted_clears_watchdog_then_timeout_is_stale, recovery_rewalk_reverifies_live_sibling_at_rest

Comment thread docs/src/design/orchestrator/orchestrator-machine.md Outdated
Comment thread services/orchestrator/sm/README.md
Comment thread docs/src/design/orchestrator/orchestrator-model.md Outdated
Add architecture diagram and narrative to the Platform Boundary section describing the Event/Effect boundary and mechanism-neutral effects.
Enforce component-id membership once in step() via Event::component_id(),
so no handler acts on an id outside the configured chain. Also: rename
RestoreGoldenImage -> RecoverComponent, add the commit-or-lock watchdog
(pending_commit/CommitTimeout), and drop #[non_exhaustive].
- model: add §5 (recovery re-boot / at-rest re-verify) and §6 reset-must-hold note
- machine: fill the INV8 catalogue gap with verify-before-release (property test)
- model/overview: rename the 'platform shell' concept to 'platform' throughout
…rdicts

- quiesce_all: assert reset on every live component before a recovery re-walk,
  so recovery is a genuine platform re-boot and no live component is re-verified
  while running (closes the TOCTOU on already-released siblings)
- VerificationPassed: release only the component under verification (chain[cursor]);
  drop stale/out-of-turn verdicts, mirroring the INV9 guard on ComponentReady
- tests: quiesce coverage (multi-sibling, at-rest re-verify), plus a property
  test (INV8) asserting verify-before-release across random event sequences
chrysh added a commit to 9elements/openprot that referenced this pull request Aug 4, 2026
Cover the downstream firmware-update lifecycle with host tests that
assert only on the device's externally visible signals — reset
transitions, what was flashed while the device was held, which slot it
booted, what got committed:

- update success: the device is held in reset the whole time firmware
  is written (programs_while_running == 0), rebooted on the new slot,
  and committed only after the booted state was observed
  (commit_preceded_by_ready).
- failed firmware write: the device's broken write path surfaces
  through the flash seam; nothing is armed or committed and the device
  comes back up on its old slot, target slot untouched.
- corrupt staged image: rejected during staging, staged bytes
  discarded, the running device never disturbed.
- trial-boot timeout: no boot evidence within the window rolls the
  trial back and reboots the still-committed old slot.
- negative control: a deliberately careless driver that flashes a live
  device and commits without boot evidence is caught by the
  instrumentation — proof the positive assertions aren't vacuous while
  the drivers are still test-local.

stage_update/activate_update are the test-local reference rendering of
the trial-boot protocol from the BootControl docs (hold, flash, arm
trial, release, observe, commit/rollback). They are the executable
spec for the orchestrator from PR OpenPRoT#357: when it merges, the real state
machine replaces these drivers and the assertions stay.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
chrysh added a commit to 9elements/openprot that referenced this pull request Aug 5, 2026
Cover the downstream firmware-update lifecycle with host tests that
assert only on the device's externally visible signals — reset
transitions, what was flashed while the device was held, which slot it
booted, what got committed:

- update success: the device is held in reset the whole time firmware
  is written (programs_while_running == 0), rebooted on the new slot,
  and committed only after the booted state was observed
  (commit_preceded_by_ready).
- failed firmware write: the device's broken write path surfaces
  through the flash seam; nothing is armed or committed and the device
  comes back up on its old slot, target slot untouched.
- corrupt staged image: rejected during staging, staged bytes
  discarded, the running device never disturbed.
- trial-boot timeout: no boot evidence within the window rolls the
  trial back and reboots the still-committed old slot.
- negative control: a deliberately careless driver that flashes a live
  device and commits without boot evidence is caught by the
  instrumentation — proof the positive assertions aren't vacuous while
  the drivers are still test-local.

stage_update/activate_update are the test-local reference rendering of
the trial-boot protocol from the BootControl docs (hold, flash, arm
trial, release, observe, commit/rollback). They are the executable
spec for the orchestrator from PR OpenPRoT#357: when it merges, the real state
machine replaces these drivers and the assertions stay.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
chrysh added a commit to 9elements/openprot that referenced this pull request Aug 5, 2026
Host tests asserting only on externally visible signals: successful
update (device held in reset while flashed, committed only after
observed boot), failed firmware write, corrupt staged image, trial
timeout rollback, and a negative control proving the instrumentation
catches a careless driver. stage_update/activate_update are test-local
stand-ins for the PR OpenPRoT#357 orchestrator: when it merges, the drivers go
and the assertions stay.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
@rusty1968
rusty1968 merged commit ba18481 into OpenPRoT:main Aug 5, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants