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:
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"])
);
}