From 1c2979deebdd0b62629a91423078ae34da0116a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sun, 9 Aug 2026 13:58:06 +0300 Subject: [PATCH] safety: add fail-closed permit supervisor --- libs/kaptan-safety/Cargo.toml | 13 + libs/kaptan-safety/src/lib.rs | 606 ++++++++++++++++++++++++++++++++++ 2 files changed, 619 insertions(+) create mode 100644 libs/kaptan-safety/Cargo.toml create mode 100644 libs/kaptan-safety/src/lib.rs diff --git a/libs/kaptan-safety/Cargo.toml b/libs/kaptan-safety/Cargo.toml new file mode 100644 index 0000000..4f9112f --- /dev/null +++ b/libs/kaptan-safety/Cargo.toml @@ -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" } diff --git a/libs/kaptan-safety/src/lib.rs b/libs/kaptan-safety/src/lib.rs new file mode 100644 index 0000000..281e63d --- /dev/null +++ b/libs/kaptan-safety/src/lib.rs @@ -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 { + 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, + permit: Option, +} + +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 { + 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, + prearm_since: Option, + last_evaluation: Option, + 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 { + 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) + ); + } +}