lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit ea5ee6d8cfcd5f854fb859868c8e6cd97f9d2afe
parent 78de9512faa5af442a703c5736a590b8ad6cd542
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 01:04:42 +0000

service-host: add service phase contract

- Add the exact shared lifecycle phase and boolean readiness values.
- Validate bounded canonical reason codes and collections.
- Enforce the governed startup, health, shutdown, and failure transitions.
- Verify every phase edge, degraded readiness, and wire serialization.

Diffstat:
Mcrates/service_host/src/lib.rs | 5+++++
Acrates/service_host/src/status/mod.rs | 7+++++++
Acrates/service_host/src/status/phase.rs | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/service_host/src/status/reason.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/tests/package_boundary.rs | 2+-
5 files changed, 530 insertions(+), 1 deletion(-)

diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -5,6 +5,7 @@ pub mod build_info; pub mod entropy; pub mod error; +pub mod status; pub mod time; pub use build_info::{ @@ -13,6 +14,10 @@ pub use build_info::{ }; pub use entropy::{EntropyError, EntropySource, SystemEntropy}; pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError}; +pub use status::{ + CommonReasonCode, Readiness, ReasonCode, ReasonCodes, ServiceOperationalState, ServicePhase, + StatusContractError, +}; pub use time::{ MonotonicClock, MonotonicClockError, MonotonicDeadline, MonotonicTime, SystemMonotonicClock, SystemWallClock, UnixTimeSeconds, WallClock, WallClockError, diff --git a/crates/service_host/src/status/mod.rs b/crates/service_host/src/status/mod.rs @@ -0,0 +1,7 @@ +//! Common service lifecycle and status value objects. + +mod phase; +mod reason; + +pub use phase::{Readiness, ServiceOperationalState, ServicePhase, StatusContractError}; +pub use reason::{CommonReasonCode, REASON_CODES_MAX_ITEMS, ReasonCode, ReasonCodes}; diff --git a/crates/service_host/src/status/phase.rs b/crates/service_host/src/status/phase.rs @@ -0,0 +1,287 @@ +//! Service-neutral lifecycle phases and readiness state. + +use core::fmt; + +use serde::{Deserialize, Serialize}; + +use super::ReasonCodes; + +/// Stable lifecycle phase shared by hardened services. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServicePhase { + Starting, + Ready, + Degraded, + Unready, + Stopping, + Failed, +} + +impl ServicePhase { + /// Returns whether moving from this phase to `next` is legal. + #[must_use] + pub const fn can_transition_to(self, next: Self) -> bool { + if self as u8 == next as u8 { + return true; + } + match self { + Self::Starting => matches!(next, Self::Ready | Self::Degraded | Self::Failed), + Self::Ready | Self::Degraded | Self::Unready => matches!( + next, + Self::Ready | Self::Degraded | Self::Unready | Self::Stopping | Self::Failed + ), + Self::Stopping => matches!(next, Self::Failed), + Self::Failed => false, + } + } +} + +/// Readiness is serialized as the exact boolean required by status contracts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Readiness(bool); + +impl Readiness { + pub const READY: Self = Self(true); + pub const NOT_READY: Self = Self(false); + + #[must_use] + pub const fn is_ready(self) -> bool { + self.0 + } +} + +/// One validated service-neutral operational observation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceOperationalState { + phase: ServicePhase, + readiness: Readiness, + reasons: ReasonCodes, +} + +impl ServiceOperationalState { + /// Constructs a phase/readiness pair, rejecting contradictory combinations. + pub fn new( + phase: ServicePhase, + readiness: Readiness, + reasons: ReasonCodes, + ) -> Result<Self, StatusContractError> { + validate_readiness(phase, readiness)?; + Ok(Self { + phase, + readiness, + reasons, + }) + } + + #[must_use] + pub const fn phase(&self) -> ServicePhase { + self.phase + } + + #[must_use] + pub const fn readiness(&self) -> Readiness { + self.readiness + } + + #[must_use] + pub const fn reasons(&self) -> &ReasonCodes { + &self.reasons + } + + /// Applies a legal transition while preserving the last valid state on failure. + pub fn transition_to( + &mut self, + phase: ServicePhase, + readiness: Readiness, + reasons: ReasonCodes, + ) -> Result<(), StatusContractError> { + if !self.phase.can_transition_to(phase) { + return Err(StatusContractError::IllegalTransition { + from: self.phase, + to: phase, + }); + } + validate_readiness(phase, readiness)?; + self.phase = phase; + self.readiness = readiness; + self.reasons = reasons; + Ok(()) + } +} + +fn validate_readiness( + phase: ServicePhase, + readiness: Readiness, +) -> Result<(), StatusContractError> { + let legal = match phase { + ServicePhase::Ready => readiness.is_ready(), + ServicePhase::Degraded => true, + ServicePhase::Starting + | ServicePhase::Unready + | ServicePhase::Stopping + | ServicePhase::Failed => !readiness.is_ready(), + }; + if legal { + Ok(()) + } else { + Err(StatusContractError::InvalidReadiness { phase, readiness }) + } +} + +/// Validation failure for common lifecycle and reason contracts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StatusContractError { + InvalidReasonCode, + TooManyReasonCodes { + maximum: usize, + }, + InvalidReadiness { + phase: ServicePhase, + readiness: Readiness, + }, + IllegalTransition { + from: ServicePhase, + to: ServicePhase, + }, +} + +impl fmt::Display for StatusContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidReasonCode => formatter.write_str("status reason code is invalid"), + Self::TooManyReasonCodes { maximum } => { + write!(formatter, "status exceeds its {maximum}-reason limit") + } + Self::InvalidReadiness { phase, readiness } => write!( + formatter, + "readiness {} is invalid for phase {phase:?}", + readiness.is_ready() + ), + Self::IllegalTransition { from, to } => { + write!( + formatter, + "service phase transition {from:?} -> {to:?} is illegal" + ) + } + } + } +} + +impl std::error::Error for StatusContractError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legal_and_illegal_phase_transitions_are_explicit() { + let phases = [ + ServicePhase::Starting, + ServicePhase::Ready, + ServicePhase::Degraded, + ServicePhase::Unready, + ServicePhase::Stopping, + ServicePhase::Failed, + ]; + let expected = [ + [true, true, true, false, false, true], + [false, true, true, true, true, true], + [false, true, true, true, true, true], + [false, true, true, true, true, true], + [false, false, false, false, true, true], + [false, false, false, false, false, true], + ]; + for (from_index, from) in phases.into_iter().enumerate() { + for (to_index, to) in phases.into_iter().enumerate() { + assert_eq!( + from.can_transition_to(to), + expected[from_index][to_index], + "unexpected {from:?} -> {to:?} decision" + ); + } + } + + let mut state = ServiceOperationalState::new( + ServicePhase::Starting, + Readiness::NOT_READY, + ReasonCodes::empty(), + ) + .expect("starting state"); + state + .transition_to(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()) + .expect("ready transition"); + state + .transition_to( + ServicePhase::Stopping, + Readiness::NOT_READY, + ReasonCodes::empty(), + ) + .expect("stopping transition"); + + let before = state.clone(); + assert_eq!( + state.transition_to(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty(),), + Err(StatusContractError::IllegalTransition { + from: ServicePhase::Stopping, + to: ServicePhase::Ready, + }) + ); + assert_eq!(state, before); + } + + #[test] + fn degraded_readiness_is_independent_but_other_phases_are_consistent() { + for readiness in [Readiness::READY, Readiness::NOT_READY] { + assert!( + ServiceOperationalState::new( + ServicePhase::Degraded, + readiness, + ReasonCodes::empty() + ) + .is_ok() + ); + } + assert!( + ServiceOperationalState::new( + ServicePhase::Ready, + Readiness::NOT_READY, + ReasonCodes::empty() + ) + .is_err() + ); + assert!( + ServiceOperationalState::new( + ServicePhase::Failed, + Readiness::READY, + ReasonCodes::empty() + ) + .is_err() + ); + } + + #[test] + fn phase_and_readiness_serde_names_match_frozen_contracts() { + let phases = [ + (ServicePhase::Starting, "\"starting\""), + (ServicePhase::Ready, "\"ready\""), + (ServicePhase::Degraded, "\"degraded\""), + (ServicePhase::Unready, "\"unready\""), + (ServicePhase::Stopping, "\"stopping\""), + (ServicePhase::Failed, "\"failed\""), + ]; + for (phase, json) in phases { + assert_eq!(serde_json::to_string(&phase).expect("phase"), json); + assert_eq!( + serde_json::from_str::<ServicePhase>(json).expect("phase"), + phase + ); + } + assert_eq!(serde_json::to_string(&Readiness::READY).unwrap(), "true"); + assert_eq!( + serde_json::to_string(&Readiness::NOT_READY).unwrap(), + "false" + ); + } +} diff --git a/crates/service_host/src/status/reason.rs b/crates/service_host/src/status/reason.rs @@ -0,0 +1,230 @@ +//! Stable, bounded reason codes for status and health surfaces. + +use core::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::StatusContractError; + +pub const REASON_CODE_MAX_BYTES: usize = 64; +pub const REASON_CODES_MAX_ITEMS: usize = 32; + +/// Shared reason codes whose meanings are service-neutral. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommonReasonCode { + IdentityUnavailable, + DatabaseSchemaMismatch, + DatabaseReadOnly, + DatabaseLowDisk, + RequiredRelayUnavailable, + SubscriberNotActive, + SignerProviderUnavailable, + OutboxInvariantFailed, + PublicationBacklogExceeded, + AdminListenerFailed, + OperationsListenerFailed, + ShutdownInProgress, +} + +impl CommonReasonCode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::IdentityUnavailable => "identity_unavailable", + Self::DatabaseSchemaMismatch => "database_schema_mismatch", + Self::DatabaseReadOnly => "database_read_only", + Self::DatabaseLowDisk => "database_low_disk", + Self::RequiredRelayUnavailable => "required_relay_unavailable", + Self::SubscriberNotActive => "subscriber_not_active", + Self::SignerProviderUnavailable => "signer_provider_unavailable", + Self::OutboxInvariantFailed => "outbox_invariant_failed", + Self::PublicationBacklogExceeded => "publication_backlog_exceeded", + Self::AdminListenerFailed => "admin_listener_failed", + Self::OperationsListenerFailed => "operations_listener_failed", + Self::ShutdownInProgress => "shutdown_in_progress", + } + } +} + +/// A validated stable status reason code. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ReasonCode(String); + +impl ReasonCode { + pub fn new(value: impl Into<String>) -> Result<Self, StatusContractError> { + let value = value.into(); + if !valid_reason_code(&value) { + return Err(StatusContractError::InvalidReasonCode); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<CommonReasonCode> for ReasonCode { + fn from(value: CommonReasonCode) -> Self { + Self(value.as_str().to_owned()) + } +} + +impl fmt::Display for ReasonCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for ReasonCode { + type Err = StatusContractError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } +} + +impl Serialize for ReasonCode { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ReasonCode { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// A unique, canonically sorted, bounded reason-code collection. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ReasonCodes(Vec<ReasonCode>); + +impl ReasonCodes { + #[must_use] + pub const fn empty() -> Self { + Self(Vec::new()) + } + + pub fn new(values: impl IntoIterator<Item = ReasonCode>) -> Result<Self, StatusContractError> { + let mut values: Vec<_> = values.into_iter().collect(); + values.sort_unstable(); + values.dedup(); + if values.len() > REASON_CODES_MAX_ITEMS { + return Err(StatusContractError::TooManyReasonCodes { + maximum: REASON_CODES_MAX_ITEMS, + }); + } + Ok(Self(values)) + } + + #[must_use] + pub fn as_slice(&self) -> &[ReasonCode] { + &self.0 + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl<'de> Deserialize<'de> for ReasonCodes { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let raw = Vec::<ReasonCode>::deserialize(deserializer)?; + if raw.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(serde::de::Error::custom( + "reason codes must be unique and canonically sorted", + )); + } + Self::new(raw).map_err(serde::de::Error::custom) + } +} + +fn valid_reason_code(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + value.len() <= REASON_CODE_MAX_BYTES + && first.is_ascii_lowercase() + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn common_codes_are_stable_valid_and_unique() { + let codes = [ + CommonReasonCode::IdentityUnavailable, + CommonReasonCode::DatabaseSchemaMismatch, + CommonReasonCode::DatabaseReadOnly, + CommonReasonCode::DatabaseLowDisk, + CommonReasonCode::RequiredRelayUnavailable, + CommonReasonCode::SubscriberNotActive, + CommonReasonCode::SignerProviderUnavailable, + CommonReasonCode::OutboxInvariantFailed, + CommonReasonCode::PublicationBacklogExceeded, + CommonReasonCode::AdminListenerFailed, + CommonReasonCode::OperationsListenerFailed, + CommonReasonCode::ShutdownInProgress, + ]; + let reasons = ReasonCodes::new(codes.map(ReasonCode::from)).expect("common reasons"); + assert_eq!(reasons.as_slice().len(), codes.len()); + assert!(reasons.as_slice().windows(2).all(|pair| pair[0] < pair[1])); + } + + #[test] + fn reason_code_validation_matches_frozen_contract() { + for valid in ["a", "database_low_disk", "myc_reason_01"] { + assert_eq!(ReasonCode::new(valid).unwrap().as_str(), valid); + } + assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES)).is_ok()); + + for invalid in ["", "Upper", "1reason", "a-b", "a.b", "a b", "café"] { + assert_eq!( + ReasonCode::new(invalid), + Err(StatusContractError::InvalidReasonCode) + ); + } + assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES + 1)).is_err()); + } + + #[test] + fn collections_sort_deduplicate_bound_and_serialize_canonically() { + let reasons = ReasonCodes::new([ + ReasonCode::new("z_reason").unwrap(), + ReasonCode::new("a_reason").unwrap(), + ReasonCode::new("z_reason").unwrap(), + ]) + .expect("bounded reasons"); + assert_eq!( + serde_json::to_string(&reasons).unwrap(), + r#"["a_reason","z_reason"]"# + ); + + let over = (0..=REASON_CODES_MAX_ITEMS) + .map(|index| ReasonCode::new(format!("reason_{index:02}")).unwrap()); + assert_eq!( + ReasonCodes::new(over), + Err(StatusContractError::TooManyReasonCodes { + maximum: REASON_CODES_MAX_ITEMS + }) + ); + assert!(serde_json::from_str::<ReasonCodes>(r#"["z_reason","a_reason"]"#).is_err()); + assert!(serde_json::from_str::<ReasonCodes>(r#"["a_reason","a_reason"]"#).is_err()); + } +} diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -23,7 +23,7 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { ); assert_eq!( public_modules(ROOT), - BTreeSet::from(["build_info", "entropy", "error", "time"]) + BTreeSet::from(["build_info", "entropy", "error", "status", "time"]) ); }