Skip to content

orchestrator: Device table is the single source of the chain of trust - #26

Draft
chrysh wants to merge 16 commits into
add-boot-walkfrom
device-table-single-source
Draft

orchestrator: Device table is the single source of the chain of trust#26
chrysh wants to merge 16 commits into
add-boot-walkfrom
device-table-single-source

Conversation

@chrysh

@chrysh chrysh commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

TL;DR devices.rs config addition to provide chain information to orchestrator-sm

Stacked on add-boot-walk (PR OpenPRoT#397's branch).

Makes the board device table the single source of truth for the orchestrator's chain of trust, and gives every invariant exactly one checker:

  • ComponentKind/FailurePolicy move into orchestrator-config; the SM re-exports them (API unchanged).
  • DeviceConfig declares kind, failure policy, and depends_on (by device name); the new const DeviceTable wrapper is the only place cross-entry invariants are checked, so an invalid table fails the build.
  • Chain::from_table derives the chain from the table (index = id, declaration order = walk order) and is infallible — holding a DeviceTable is the proof — so ChainError and the TryFrom impl are deleted.
  • The mock board derives DEVICE_COUNT/EFFECT_CAP from its table, declares MAX_RETRY, and gains a host test driving table → chain → orchestrator end to end.

https://claude.ai/code/session_014on9N5gJLKpARXtNh4Wzib

chrysh added 16 commits August 7, 2026 10:16
…hecks

A BootCheckpoint is timing policy plus its own evidence check: a
capture-less fn handed the board's device context, so the channel
underneath never leaks past the check and an unobservable checkpoint
is unrepresentable. config.rs defines the schema (BootSignal is gone);
the board table declares the checkpoints against its own context and
error types. BootStatus stays as the shared vocabulary and absorbs
the latch-cleared-by-reset contract; GpioBootMonitor keeps its
behavior as a plain reader. BootWatch/WalkVerdict is the erased seam
the orchestrator polls — timeout and retry-budget judgment lands with
the walker that implements it.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
…al ids

The embedded fn was the more general shape, but the generality went
unused while its costs did not: the table stopped being pure data
(unprintable, unvalidatable on mechanisms, never generatable), every
check shared one &mut board context, and dispatch went indirect. A
signal id is the same check defunctionalized: data in the table, an
exhaustive match in the board's EvidenceReader — typically one per
device, so each walk borrows only its own reader. Boot-evidence
mechanisms per board are a closed set; when one can't be named, that
is a new variant in that board's enum, not an API change.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
…ence

A device that knows it failed should end the wait early, and one that
knows a retry is pointless should say so, instead of the orchestrator
burning its window and budget to find out. BootStatus::Failed splits
into FailedRetriable (consumes budget immediately) and FailedFatal
(ends the boot regardless of budget). Timeouts stay the orchestrator's
own judgment — hung devices report nothing — and channel trouble stays
in the reader's Error, distinct from a device-reported verdict.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
A timeout is never on the wire: a hung endpoint reads Booting forever,
and only the orchestrator's clock turns silence into a verdict. The
message path carries the active verdicts (device failure codes) and
channel trouble, each on its own channel.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Retry re-arms the window, but the caller had no way to know until
when — it would have had to reach into the checkpoint's timeout and
do the walker's arithmetic itself. Retry now carries deadline_millis
exactly like Waiting: one scheduling rule for both verdicts.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Adapter crates cannot implement EvidenceReader themselves — a board's
signal vocabulary G is not theirs to know. Show the intended shape on
the trait: the board impl owns the match, the hardware binding is made
once at construction, the signal id proves the right reader was wired.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
…aning

Failure reports identify a checkpoint by name, so a duplicate within a
device would make them ambiguous — validate now rejects it at build
time (str comparison by hand: == on &str is not const). Also state
explicitly that max_retries=0 means the one attempt is all the device
gets.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
The signal field now says on the spot why it is an id and who resolves
it, and validate points at the mock table, which demonstrates the
board-local const fence for checks the generic validate cannot do
(gpio line within the bank).

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Whether a device must attest before its update commits follows from
what kind of device it is (iRoT-backed or symbiont — the
orchestrator's ComponentKind); the CSA defines only that distinction.
A second table knob could only agree with the kind or contradict it.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
WalkVerdict now reports observation only: Failed{checkpoint, cause}
replaces Retry/Dead/retries_left — the state machine's
ComponentStatus.retry and Recovering→RecoveryFailed path already own
those decisions, and a second counter could only agree or disagree
with the first. max_retries leaves the table for the same reason: a
retry re-resets the device and re-runs the whole walk, so budgets are
per boot attempt, owned where boot attempts are owned. The device's
own judgment still flows up as FailureCause::{TimedOut,
DeviceRetriable, DeviceFatal} — the one input the retry decision
needs.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Pin three facts the docs left implicit: checkpoint timeouts
are table data the walk consumes — the clockless state machine never
sees a duration, a component's boot timeout is just its walk over the
windows; the device table is the authority the chain is built from;
and Complete maps to ComponentReady or Booted by component kind, in
the shell.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Every schema check is per-device, so the constructors can run them
all. BootCheckpoint::new and DeviceConfig::new are const fn -- board
tables still build in const context, so a bad table is still a build
error -- but the fields are private now, and a checkpoint or device
entry that violates the schema cannot be constructed at all.

The free validate() is gone with the loophole it carried: it had to
be remembered, and a board table that dropped the const fence
compiled fine while broken. Construction is the one gate every entry
passes. Board-local checks keep the const-fence pattern
(validate_signals in the mock table), reading through the new
accessors.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Within the crate only EvidenceReader consumes BootStatus — it is the
trait's return vocabulary — so the enum does not earn a module of its
own. The crate-root re-export is unchanged; no import anywhere moves.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
Kind and failure policy are per-device board policy, so their vocabulary
belongs to the device-table schema crate, where boards declare the rest
of their boot behavior; the state machine depends on the schema crate
and re-exports both types, keeping the reducer's API unchanged while
giving the classification a single home next to the table that will
declare it.

Assisted-by: Claude:claude-fable-5
One fact, one home: what a device is (ComponentKind), what its failure
means for the platform (FailurePolicy), and which earlier device it
cascades with (depends_on, by name) are board facts, so they belong in
the board's device table next to its reset wiring and checkpoints. The
new const DeviceTable wrapper is the single place cross-entry invariants
are checked — non-empty, within the orchestrator's cursor bound, unique
device names, and every dependency naming a strictly earlier entry — so
an invalid table is unconstructible and fails the build; per-entry
constructors cannot see the whole table, which is why the wrapper owns
these checks. The mock board declares its archetypes accordingly: the
direct-flash bmc is Passive and Required, the self-updating nic is
Active and Isolable.

Assisted-by: Claude:claude-fable-5
Holding a DeviceTable is proof of the chain invariants — its const
constructor already rejected every malformed table at build time — so
Chain::from_table is infallible and ChainError with its TryFrom impl, a
second copy of the same rules that could drift into disagreement, is
deleted; each invariant now has exactly one checker and the type system
carries the proof across the crate boundary. The conversion derives
everything: table index becomes the component id (declaration order is
walk order), kind and policy are copied, and depends_on names resolve to
the ids of their earlier entries. The mock board derives its chain
capacity and effect-buffer size from the table and declares the one
underivable board fact, the retry budget; its new host test drives
table, chain, and orchestrator end to end, checking that the passive bmc
release advances the walk speculatively. Reducer tests keep building
ad-hoc chains through a cfg(test)-only constructor, so no unchecked door
exists in production builds.

Assisted-by: Claude:claude-fable-5
@chrysh chrysh closed this Aug 8, 2026
@chrysh
chrysh deleted the device-table-single-source branch August 8, 2026 08:16
@chrysh
chrysh restored the device-table-single-source branch August 8, 2026 08:18
@chrysh chrysh reopened this Aug 8, 2026
@chrysh

chrysh commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Same work as OpenPRoT#404

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.

1 participant