safety: add fail-closed permit supervisor

This commit is contained in:
Onur Özkan committed 2026-08-09 13:58:06 +03:00
1 parent 757457bb83
commit 1c2979deeb
2 files changed
+619

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
[package]
name = "kaptan-safety"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-time = { path = "../kaptan-time" }
+606
View File
@@ -0,0 +1,606 @@
//! Bounded safety supervisor for actuator authorization.
//!
//! The platform turns estimator, command, and transport health into the
//! explicit inputs below. This crate does not authenticate a command or drive
//! hardware; a reviewed platform must evaluate it before every output commit
//! and make the output port validate the returned opaque permit.
#![no_std]
use kaptan_time::Timestamp;
/// Supervisor arming state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SafetyState {
/// Physical outputs inhibited.
Disarmed,
/// Checks are continuously passing during the configured dwell period.
PreArm,
/// Outputs may be enabled by a platform transport that validates a permit.
Armed,
/// Outputs are inhibited pending an explicit disarm and new arm request.
Failsafe,
}
/// Typed reason for refusing or ending actuator authorization.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SafetyFailure {
/// Estimator is absent, stale, or non-finite.
EstimatorUnhealthy,
/// Required inertial sensor stream is stale.
ImuStale,
/// Required command/link stream is stale.
LinkStale,
/// Actuator transport reports a fault.
ActuatorFault,
/// No current explicit arm transaction exists.
ArmNotRequested,
/// The platform supplied time that moved backwards.
ClockRegression,
/// A non-wrapping actuator-permit epoch could not be allocated.
PermitEpochExhausted,
}
/// Invalid safety configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SafetyConfigError {
/// A zero dwell would permit two evaluations at one timestamp to arm.
ZeroPrearmDwell,
}
/// Explicit platform policy for the safety state machine.
///
/// This is intentionally not `Default`: a vehicle or simulator must choose
/// and review its own continuously-healthy prearm dwell.
///
/// TODO(configuration): this value is currently selected by the build-time
/// vehicle profile. A ground-station configuration service must validate it
/// as part of one persisted safety/output profile and only activate the new
/// revision while disarmed; a live duration change must not restart or extend
/// an arming transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetyConfig {
prearm_dwell_microseconds: u64,
}
impl SafetyConfig {
/// Builds a policy with a strictly positive prearm dwell.
pub const fn new(prearm_dwell_microseconds: u64) -> Result<Self, SafetyConfigError> {
if prearm_dwell_microseconds == 0 {
Err(SafetyConfigError::ZeroPrearmDwell)
} else {
Ok(Self {
prearm_dwell_microseconds,
})
}
}
/// Returns the required continuously-healthy dwell duration.
pub const fn prearm_dwell_microseconds(self) -> u64 {
self.prearm_dwell_microseconds
}
}
/// Platform-normalized bounded health input. Every `true` is a current
/// measurement, not a promise about sensor calibration or physical safety.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetyInputs {
/// Estimator finite/initialized health is acceptable for the requested mode.
pub estimator_healthy: bool,
/// IMU receipt is within the static platform freshness limit.
pub imu_fresh: bool,
/// Control command/link receipt is within its freshness limit.
///
/// TODO(flight-config): an external-control flight mode must map lease
/// expiry into a reviewed bounded hold or landing mode while estimator and
/// actuator health remain valid; it must not pass that expired lease here
/// as an immediate physical output inhibit. Fatal sensor/control/transport
/// faults remain inhibit conditions.
pub link_fresh: bool,
/// Output transport is initialized and fault-free.
pub actuator_healthy: bool,
}
/// Authorization token that external code cannot construct or clone.
///
/// A retained token cannot authorize output after a supervisor transition.
#[derive(Debug, Eq, PartialEq)]
pub struct ActuatorPermit {
epoch: u64,
}
/// Observable supervisor transition decision.
///
/// The token is deliberately opaque and non-`Copy`. A transport must call
/// [`SafetySupervisor::permit_is_current`] immediately before a nonzero
/// output commit; the decision itself is not an output command.
#[derive(Debug, Eq, PartialEq)]
pub struct SafetyDecision {
state: SafetyState,
failure: Option<SafetyFailure>,
permit: Option<ActuatorPermit>,
}
impl SafetyDecision {
/// Resulting supervisor state.
pub const fn state(&self) -> SafetyState {
self.state
}
/// Reason whenever physical output is inhibited.
pub const fn failure(&self) -> Option<SafetyFailure> {
self.failure
}
/// Returns the authorization token only while the decision is armed.
pub const fn permit(&self) -> Option<&ActuatorPermit> {
self.permit.as_ref()
}
}
/// Small safety supervisor with no queues, heap, peripheral access, or hidden
/// clock. A platform supplies one monotonic timestamp to every transition.
pub struct SafetySupervisor {
config: SafetyConfig,
state: SafetyState,
last_failure: Option<SafetyFailure>,
prearm_since: Option<Timestamp>,
last_evaluation: Option<Timestamp>,
permit_epoch: u64,
}
impl SafetySupervisor {
/// Starts disarmed and inhibited irrespective of command availability.
pub const fn new(config: SafetyConfig) -> Self {
Self {
config,
state: SafetyState::Disarmed,
last_failure: Some(SafetyFailure::ArmNotRequested),
prearm_since: None,
last_evaluation: None,
permit_epoch: 0,
}
}
/// Consumes one explicit arm transaction.
///
/// Only a healthy request while disarmed enters prearm. A failed request is
/// never retained for a later healthy evaluation.
pub fn request_arm(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
if !self.observe_time(now) {
return self.decision();
}
if self.state != SafetyState::Disarmed {
return self.decision();
}
if let Some(failure) = preflight_failure(inputs) {
self.last_failure = Some(failure);
return self.decision();
}
self.state = SafetyState::PreArm;
self.prearm_since = Some(now);
self.last_failure = None;
self.decision()
}
/// Explicitly inhibits output and consumes any outstanding arm state.
pub fn request_disarm(&mut self) {
if self.state == SafetyState::Armed && !self.invalidate_active_permit() {
return;
}
self.state = SafetyState::Disarmed;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::ArmNotRequested);
}
/// Begins a new platform clock epoch after output has been inhibited.
///
/// This is for a simulator restart or an explicitly reviewed platform
/// clock reset. It preserves the monotonically increasing permit epoch so
/// a token from before the reset can never validate after rearming.
pub fn reset_time_epoch(&mut self) {
self.request_disarm();
self.prearm_since = None;
self.last_evaluation = None;
}
/// Evaluates preflight/failsafe inputs supplied by a platform that owns
/// their timestamp and freshness policy. Platforms must call this before
/// every physical output commit.
pub fn evaluate(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
if !self.observe_time(now) {
return self.decision();
}
let failure = preflight_failure(inputs);
match self.state {
SafetyState::Disarmed => {
self.last_failure = failure.or(Some(SafetyFailure::ArmNotRequested));
}
SafetyState::PreArm => {
if let Some(reason) = failure {
self.state = SafetyState::Disarmed;
self.prearm_since = None;
self.last_failure = Some(reason);
} else if self.prearm_since.is_some_and(|since| {
elapsed_at_least(now, since, self.config.prearm_dwell_microseconds)
}) {
self.enter_armed();
}
}
SafetyState::Armed => {
if let Some(reason) = failure {
self.leave_armed(SafetyState::Failsafe, reason);
}
}
SafetyState::Failsafe => {}
}
self.decision()
}
/// Checks an opaque token at the output boundary.
pub fn permit_is_current(&self, permit: &ActuatorPermit) -> bool {
self.state == SafetyState::Armed && self.permit_epoch == permit.epoch
}
/// Returns the current state without granting a bypass permission.
pub const fn state(&self) -> SafetyState {
self.state
}
fn observe_time(&mut self, now: Timestamp) -> bool {
if self.last_evaluation.is_some_and(|previous| now < previous) {
self.prearm_since = None;
if self.state == SafetyState::Armed {
self.leave_armed(SafetyState::Failsafe, SafetyFailure::ClockRegression);
} else if self.state == SafetyState::PreArm {
self.state = SafetyState::Failsafe;
self.last_failure = Some(SafetyFailure::ClockRegression);
} else {
self.last_failure = Some(SafetyFailure::ClockRegression);
}
return false;
}
self.last_evaluation = Some(now);
true
}
fn enter_armed(&mut self) {
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
self.state = SafetyState::Failsafe;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
return;
};
self.permit_epoch = next_epoch;
self.state = SafetyState::Armed;
self.prearm_since = None;
self.last_failure = None;
}
fn leave_armed(&mut self, next_state: SafetyState, failure: SafetyFailure) {
if self.invalidate_active_permit() {
self.state = next_state;
self.prearm_since = None;
self.last_failure = Some(failure);
}
}
fn invalidate_active_permit(&mut self) -> bool {
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
self.state = SafetyState::Failsafe;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
return false;
};
self.permit_epoch = next_epoch;
true
}
fn decision(&self) -> SafetyDecision {
SafetyDecision {
state: self.state,
failure: self.last_failure,
permit: if self.state == SafetyState::Armed {
Some(ActuatorPermit {
epoch: self.permit_epoch,
})
} else {
None
},
}
}
}
fn elapsed_at_least(now: Timestamp, since: Timestamp, duration_microseconds: u64) -> bool {
now.as_microseconds() - since.as_microseconds() >= duration_microseconds
}
fn preflight_failure(inputs: SafetyInputs) -> Option<SafetyFailure> {
if !inputs.estimator_healthy {
Some(SafetyFailure::EstimatorUnhealthy)
} else if !inputs.imu_fresh {
Some(SafetyFailure::ImuStale)
} else if !inputs.link_fresh {
Some(SafetyFailure::LinkStale)
} else if !inputs.actuator_healthy {
Some(SafetyFailure::ActuatorFault)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const DWELL_MICROSECONDS: u64 = 100;
fn supervisor() -> SafetySupervisor {
SafetySupervisor::new(SafetyConfig::new(DWELL_MICROSECONDS).unwrap())
}
fn time(microseconds: u64) -> Timestamp {
Timestamp::from_microseconds(microseconds)
}
fn healthy() -> SafetyInputs {
SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
link_fresh: true,
actuator_healthy: true,
}
}
fn arm(supervisor: &mut SafetySupervisor) -> SafetyDecision {
assert_eq!(
supervisor.request_arm(time(10), healthy()).state(),
SafetyState::PreArm
);
supervisor.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
}
#[test]
fn configuration_requires_nonzero_dwell() {
assert_eq!(
SafetyConfig::new(0),
Err(SafetyConfigError::ZeroPrearmDwell)
);
assert_eq!(
SafetyConfig::new(DWELL_MICROSECONDS)
.unwrap()
.prearm_dwell_microseconds(),
DWELL_MICROSECONDS
);
}
#[test]
fn failed_arm_is_not_latched_for_later_health() {
let mut safety = supervisor();
let mut unhealthy = healthy();
unhealthy.imu_fresh = false;
assert_eq!(
safety.request_arm(time(1), unhealthy).failure(),
Some(SafetyFailure::ImuStale)
);
let decision = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Disarmed);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::ArmNotRequested));
}
#[test]
fn dwell_requires_elapsed_monotonic_time() {
let mut safety = supervisor();
assert_eq!(
safety.request_arm(time(10), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
safety.evaluate(time(10), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
safety
.evaluate(time(10 + DWELL_MICROSECONDS - 1), healthy())
.state(),
SafetyState::PreArm
);
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Armed);
assert!(decision.permit().is_some());
}
#[test]
fn prearm_fault_consumes_transaction() {
let mut safety = supervisor();
safety.request_arm(time(10), healthy());
let mut stale = healthy();
stale.link_fresh = false;
assert_eq!(
safety.evaluate(time(20), stale).state(),
SafetyState::Disarmed
);
assert_eq!(
safety
.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
.state(),
SafetyState::Disarmed
);
}
#[test]
fn armed_fault_latches_failsafe() {
let mut safety = supervisor();
let armed = arm(&mut safety);
assert!(safety.permit_is_current(armed.permit().unwrap()));
let mut stale = healthy();
stale.link_fresh = false;
let decision = safety.evaluate(time(111), stale);
assert_eq!(decision.state(), SafetyState::Failsafe);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::LinkStale));
assert_eq!(
safety.evaluate(time(112), healthy()).state(),
SafetyState::Failsafe
);
}
#[test]
fn time_regression_inhibits_and_latches_failsafe() {
let mut safety = supervisor();
let armed = arm(&mut safety);
let permit = armed.permit().unwrap();
assert!(safety.permit_is_current(permit));
let decision = safety.evaluate(time(109), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
assert!(!safety.permit_is_current(permit));
}
#[test]
fn failsafe_requires_explicit_disarm_before_a_fresh_arm_request() {
let mut safety = supervisor();
let _armed = arm(&mut safety);
let mut stale = healthy();
stale.link_fresh = false;
safety.evaluate(time(111), stale);
assert_eq!(
safety.request_arm(time(112), healthy()).state(),
SafetyState::Failsafe
);
safety.request_disarm();
assert_eq!(
safety.request_arm(time(113), healthy()).state(),
SafetyState::PreArm
);
}
#[test]
fn stale_permit_cannot_authorize_after_rearm() {
let mut safety = supervisor();
let first = arm(&mut safety);
let first_permit = first.permit().unwrap();
assert!(safety.permit_is_current(first_permit));
safety.request_disarm();
assert!(!safety.permit_is_current(first_permit));
assert_eq!(
safety.request_arm(time(120), healthy()).state(),
SafetyState::PreArm
);
let second = safety.evaluate(time(120 + DWELL_MICROSECONDS), healthy());
assert!(safety.permit_is_current(second.permit().unwrap()));
assert!(!safety.permit_is_current(first_permit));
}
#[test]
fn platform_epoch_reset_accepts_a_new_clock_without_revalidating_permits() {
let mut safety = supervisor();
let armed = arm(&mut safety);
let permit = armed.permit().unwrap();
safety.reset_time_epoch();
assert_eq!(safety.state(), SafetyState::Disarmed);
assert!(!safety.permit_is_current(permit));
assert_eq!(
safety.request_arm(time(1), healthy()).state(),
SafetyState::PreArm
);
let rearmed = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
assert!(safety.permit_is_current(rearmed.permit().unwrap()));
assert!(!safety.permit_is_current(permit));
}
#[test]
fn permit_epoch_exhaustion_latches_inhibition() {
let mut safety = supervisor();
safety.permit_epoch = u64::MAX;
safety.request_arm(time(10), healthy());
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(
decision.failure(),
Some(SafetyFailure::PermitEpochExhausted)
);
assert!(decision.permit().is_none());
}
#[test]
fn preflight_priority_is_stable() {
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: false,
imu_fresh: false,
link_fresh: false,
actuator_healthy: false,
}),
Some(SafetyFailure::EstimatorUnhealthy)
);
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: true,
imu_fresh: false,
link_fresh: false,
actuator_healthy: false,
}),
Some(SafetyFailure::ImuStale)
);
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
link_fresh: true,
actuator_healthy: false,
}),
Some(SafetyFailure::ActuatorFault)
);
}
#[test]
fn clock_regressions_and_permit_exhaustion_fail_closed() {
let mut disarmed = supervisor();
disarmed.evaluate(time(10), healthy());
let decision = disarmed.request_arm(time(9), healthy());
assert_eq!(decision.state(), SafetyState::Disarmed);
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
let mut prearm = supervisor();
prearm.request_arm(time(10), healthy());
let decision = prearm.evaluate(time(9), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
let mut armed = supervisor();
let armed_decision = arm(&mut armed);
let permit = armed_decision.permit().unwrap();
assert!(armed.permit_is_current(permit));
assert_eq!(
armed.evaluate(time(111), healthy()).state(),
SafetyState::Armed
);
// This test-only corrupt state models a counter exhausted after a
// previously issued permit. Explicit disarm must still revoke output
// and latch the overflow rather than wrapping a token epoch.
armed.permit_epoch = u64::MAX;
armed.request_disarm();
assert_eq!(armed.state(), SafetyState::Failsafe);
assert_eq!(
armed.last_failure,
Some(SafetyFailure::PermitEpochExhausted)
);
// A failsafe transition uses the same non-wrapping revocation path.
let mut overflow_on_fault = supervisor();
overflow_on_fault.state = SafetyState::Armed;
overflow_on_fault.permit_epoch = u64::MAX;
let mut stale = healthy();
stale.link_fresh = false;
let decision = overflow_on_fault.evaluate(time(1), stale);
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(
decision.failure(),
Some(SafetyFailure::PermitEpochExhausted)
);
}
}