///|
/// Errors raised while defining or evaluating bounded autonomy rules.
pub(all) suberror PolicyError {
EmptyRuleId
EmptyAction
InvalidWindow
InvalidMaxTriggers
InvalidOfflineDuration
InvalidTimestamp
InvalidCheckpoint
UnsupportedPolicyVersion
PersistenceFailed
DuplicateRuleId(String)
ActionNotAllowed(CommandAction)
} derive(Debug, Eq)
///|
/// Conditions that can trigger a local offline rule.
pub(all) enum PolicyCondition {
OfflineFor(Int32)
HealthIs(NodeHealth)
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend PolicyCondition with ToJson::{to_json}
///|
pub extend PolicyCondition with @json.FromJson::{from_json}
///|
/// A bounded local action with a validity window and trigger budget.
pub(all) struct AutonomyRule {
id : String
condition : PolicyCondition
action : CommandAction
valid_from : Timestamp
valid_until : Timestamp
max_triggers : Int32
mut fired_count : Int32
policy_version : Int32
mut last_evaluated_at : Timestamp?
} derive(Debug, Eq, ToJson, FromJson)
///|
pub extend AutonomyRule with ToJson::{to_json}
///|
pub extend AutonomyRule with @json.FromJson::{from_json}
///|
/// Creates a validated offline autonomy rule.
pub fn AutonomyRule::new(
id~ : String,
condition~ : PolicyCondition,
action~ : CommandAction,
valid_from~ : Timestamp,
valid_until~ : Timestamp,
max_triggers~ : Int32,
) -> AutonomyRule raise PolicyError {
if id == "" {
raise PolicyError::EmptyRuleId
}
if valid_until.unix_seconds < valid_from.unix_seconds {
raise PolicyError::InvalidWindow
}
if max_triggers <= 0 {
raise PolicyError::InvalidMaxTriggers
}
match condition {
PolicyCondition::OfflineFor(seconds) if seconds < 0 =>
raise PolicyError::InvalidOfflineDuration
_ => ()
}
match action {
CommandAction::Execute(name) if name == "" => raise PolicyError::EmptyAction
_ => ()
}
{
id,
condition,
action,
valid_from,
valid_until,
max_triggers,
fired_count: 0,
policy_version: 1,
last_evaluated_at: None,
}
}
///|
/// The observations available to the offline policy engine.
pub(all) struct AutonomyContext {
lifecycle : NodeLifecycle
health : NodeHealth
offline_for : Int32
} derive(Debug, Eq)
///|
/// Creates a validated policy evaluation context.
pub fn AutonomyContext::new(
lifecycle~ : NodeLifecycle,
health~ : NodeHealth,
offline_for~ : Int32,
) -> AutonomyContext raise PolicyError {
if offline_for < 0 {
raise PolicyError::InvalidOfflineDuration
}
{ lifecycle, health, offline_for, }
}
///|
/// The safe result when no bounded rule may run.
pub(all) enum PolicyDecision {
SafeDefault
Execute(String, CommandAction)
} derive(Debug, Eq)
///|
/// Stateful evaluator for a node's offline autonomy rules.
pub(all) struct AutonomyEngine {
allowlist : CommandAllowlist
mut rules : Array[AutonomyRule]
} derive(Debug, Eq)
///|
/// Creates an evaluator and verifies every rule is allowlisted.
pub fn AutonomyEngine::new(
allowlist~ : CommandAllowlist,
rules~ : Array[AutonomyRule],
) -> AutonomyEngine raise PolicyError {
for i in 0.. rule.max_triggers ||
rule.valid_from.unix_seconds < 0 ||
rule.valid_until.unix_seconds < rule.valid_from.unix_seconds {
raise PolicyError::InvalidCheckpoint
}
match rule.last_evaluated_at {
Some(previous) if previous.unix_seconds < 0 =>
raise PolicyError::InvalidCheckpoint
_ => ()
}
if i > 0 && rule.last_evaluated_at != rules[0].last_evaluated_at {
raise PolicyError::InvalidCheckpoint
}
if !allowlist.allows(action=rule.action) {
raise PolicyError::ActionNotAllowed(rule.action)
}
for j in 0.. PolicyDecision raise PolicyError {
self.evaluate_durable(context~, at~, persist=_rules => true)
}
///|
/// Persists a consumed trigger budget before returning an action decision.
pub fn AutonomyEngine::evaluate_durable(
self : AutonomyEngine,
context~ : AutonomyContext,
at~ : Timestamp,
persist~ : (Array[AutonomyRule]) -> Bool,
) -> PolicyDecision raise PolicyError {
if at.unix_seconds < 0 {
raise PolicyError::InvalidTimestamp
}
if self.rules.length() > 0 {
match self.rules[0].last_evaluated_at {
Some(previous) if at.unix_seconds < previous.unix_seconds =>
raise PolicyError::InvalidTimestamp
Some(previous) if at.unix_seconds == previous.unix_seconds =>
return PolicyDecision::SafeDefault
_ => ()
}
}
for index in 0..= rule.max_triggers {
continue
}
if at.unix_seconds < rule.valid_from.unix_seconds ||
at.unix_seconds > rule.valid_until.unix_seconds {
continue
}
if !condition_matches(rule.condition, context) {
continue
}
self.rules[index].fired_count = rule.fired_count + 1
if !persist(self.checkpoint()) {
raise PolicyError::PersistenceFailed
}
return PolicyDecision::Execute(rule.id, rule.action)
}
PolicyDecision::SafeDefault
}
///|
/// Captures trigger budgets for persistence across process restarts.
pub fn AutonomyEngine::checkpoint(self : AutonomyEngine) -> Array[AutonomyRule] {
copy_rules(self.rules)
}
///|
/// Restores an evaluator from persisted rule counters.
pub fn AutonomyEngine::restore(
allowlist~ : CommandAllowlist,
rules~ : Array[AutonomyRule],
) -> AutonomyEngine raise PolicyError {
AutonomyEngine::new(allowlist~, rules~)
}
///|
fn condition_matches(
condition : PolicyCondition,
context : AutonomyContext,
) -> Bool {
match condition {
PolicyCondition::OfflineFor(seconds) => context.offline_for >= seconds
PolicyCondition::HealthIs(health) => context.health == health
}
}
///|
fn copy_rules(rules : Array[AutonomyRule]) -> Array[AutonomyRule] {
let copy : Array[AutonomyRule] = []
for rule in rules {
copy.push({
id: rule.id,
condition: rule.condition,
action: rule.action,
valid_from: rule.valid_from,
valid_until: rule.valid_until,
max_triggers: rule.max_triggers,
fired_count: rule.fired_count,
policy_version: rule.policy_version,
last_evaluated_at: rule.last_evaluated_at,
})
}
copy
}