arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! The lifecycle state values and their atomic encoding (AP2.1-10).
//!
//! [`LifecycleState`] is the four-state machine an Arcature application
//! process moves through during its lifetime:
//!
//! ```text
//! STARTING ──mark_ready──▶ READY ──begin_drain──▶ DRAINING ──mark_stopped──▶ STOPPED
//!    │                        │
//!    └──────────begin_drain──────────────────────▶ DRAINING (shutdown during startup)
//! ```
//!
//! The states are the named phases the production health endpoints and the
//! graceful-drain orchestration key off:
//!
//! - [`LifecycleState::Starting`] — the process is up but subsystems are still
//!   connecting. Liveness is true; readiness is false.
//! - [`LifecycleState::Ready`] — startup completed and the engine is serving.
//!   Readiness is true *iff* the application's registered readiness checks
//!   also pass (PROGRAM.md AP2.1-10: "Readiness must NOT become true before
//!   required app dependencies are ready").
//! - [`LifecycleState::Draining`] — a termination signal was received.
//!   Readiness goes false *first* (before new intake stops), so an upstream
//!   load balancer observing `/up/ready` stops sending traffic before the
//!   listener stops accepting.
//! - [`LifecycleState::Stopped`] — drain and shutdown are complete. The
//!   process is about to exit. Both liveness and readiness are false.
//!
//! The state is stored in an [`AtomicU8`] on the hot path (the health
//! endpoints read it on every request) so the read is lock-free. `#![forbid(
//! unsafe_code)]` is preserved — `AtomicU8` is safe Rust.

use std::sync::atomic::{AtomicU8, Ordering};

/// The lifecycle state of an Arcature application process.
///
/// Stored atomically (see [`super::Lifecycle`]); the health endpoints read
/// this on every request without taking a lock. The `repr(u8)` encoding is
/// stable so an `AtomicU8` can hold any state in a single byte.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum LifecycleState {
    /// The process is up; subsystems are still connecting. Liveness true,
    /// readiness false.
    Starting = 0,
    /// Startup completed. Readiness is true iff the registered readiness
    /// checks also pass.
    Ready = 1,
    /// A termination signal was received; readiness is false, new intake is
    /// stopping, in-flight work is draining.
    Draining = 2,
    /// Drain and shutdown are complete; the process is about to exit.
    Stopped = 3,
}

impl LifecycleState {
    /// A lowercase, stable string name for the state — used in health
    /// endpoint bodies and diagnostics. Stable across releases.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Starting => "starting",
            Self::Ready => "ready",
            Self::Draining => "draining",
            Self::Stopped => "stopped",
        }
    }

    /// Decode the atomic byte back into a [`LifecycleState`]. Returns `None`
    /// for any value outside the defined states — this never happens for a
    /// `LifecycleState` written through [`super::Lifecycle`] (the only
    /// writer), but the decode is total so a health endpoint reading a
    /// concurrent update never panics on hostile input (AGENTS.md §17).
    #[must_use]
    pub const fn from_u8(value: u8) -> Option<Self> {
        match value {
            0 => Some(Self::Starting),
            1 => Some(Self::Ready),
            2 => Some(Self::Draining),
            3 => Some(Self::Stopped),
            _ => None,
        }
    }

    /// Whether the process is live (up) in this state. Liveness is true for
    /// every state except [`Stopped`](Self::Stopped): a draining or starting
    /// process is still up and may be serving in-flight requests. This is
    /// independent of any external dependency (PROGRAM.md AP2.1-10:
    /// "liveness independent of external DB").
    #[must_use]
    pub const fn is_live(self) -> bool {
        !matches!(self, Self::Stopped)
    }
}

/// The atomic cell holding a [`LifecycleState`]. A thin wrapper around
/// [`AtomicU8`] so the encoding lives next to the enum and callers never
/// write a raw byte.
pub(crate) struct StateCell(AtomicU8);

impl StateCell {
    /// A new cell in the [`LifecycleState::Starting`] state — the entry
    /// state of every Arcature process.
    pub(crate) const fn new() -> Self {
        Self(AtomicU8::new(LifecycleState::Starting as u8))
    }

    /// Load the current state. `Acquire` ordering pairs with the `Release`
    /// stores on transitions so a reader that observes a new state also
    /// observes the side effects the writer published before the transition
    /// (e.g. readiness flags set before `Ready`).
    pub(crate) fn load(&self) -> LifecycleState {
        let value = self.0.load(Ordering::Acquire);
        LifecycleState::from_u8(value).unwrap_or(LifecycleState::Starting)
    }

    /// Store the new state. `Release` ordering publishes the writer's prior
    /// side effects to any reader that observes the new state via `Acquire`.
    pub(crate) fn store(&self, state: LifecycleState) {
        self.0.store(state as u8, Ordering::Release);
    }

    /// Atomically compare-and-swap the state from `current` to `next`.
    /// Returns `true` if the transition succeeded. `AcqRel` ordering makes
    /// both the prior side effects (Release) and the observed state
    /// (Acquire) visible across the transition.
    pub(crate) fn compare_exchange(
        &self,
        current: LifecycleState,
        next: LifecycleState,
    ) -> Result<(), LifecycleState> {
        match self.0.compare_exchange(
            current as u8,
            next as u8,
            Ordering::AcqRel,
            Ordering::Acquire,
        ) {
            Ok(_) => Ok(()),
            Err(actual) => Err(LifecycleState::from_u8(actual).unwrap_or(LifecycleState::Starting)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{LifecycleState, StateCell};

    #[test]
    fn state_roundtrips_through_u8() {
        for state in [
            LifecycleState::Starting,
            LifecycleState::Ready,
            LifecycleState::Draining,
            LifecycleState::Stopped,
        ] {
            assert_eq!(LifecycleState::from_u8(state as u8), Some(state));
        }
        assert_eq!(LifecycleState::from_u8(4), None);
        assert_eq!(LifecycleState::from_u8(255), None);
    }

    #[test]
    fn state_names_are_stable_lowercase() {
        assert_eq!(LifecycleState::Starting.as_str(), "starting");
        assert_eq!(LifecycleState::Ready.as_str(), "ready");
        assert_eq!(LifecycleState::Draining.as_str(), "draining");
        assert_eq!(LifecycleState::Stopped.as_str(), "stopped");
    }

    #[test]
    fn liveness_is_true_except_stopped() {
        assert!(LifecycleState::Starting.is_live());
        assert!(LifecycleState::Ready.is_live());
        assert!(LifecycleState::Draining.is_live());
        assert!(!LifecycleState::Stopped.is_live());
    }

    #[test]
    fn cell_starts_in_starting_and_transitions_atomically() {
        let cell = StateCell::new();
        assert_eq!(cell.load(), LifecycleState::Starting);
        // Starting -> Ready succeeds.
        assert!(
            cell.compare_exchange(LifecycleState::Starting, LifecycleState::Ready)
                .is_ok()
        );
        assert_eq!(cell.load(), LifecycleState::Ready);
        // A second Starting -> Ready fails (state is now Ready).
        let err = cell
            .compare_exchange(LifecycleState::Starting, LifecycleState::Ready)
            .expect_err("cas from stale state must fail");
        assert_eq!(err, LifecycleState::Ready);
        // Ready -> Draining -> Stopped.
        cell.compare_exchange(LifecycleState::Ready, LifecycleState::Draining)
            .expect("ready -> draining");
        assert_eq!(cell.load(), LifecycleState::Draining);
        cell.store(LifecycleState::Stopped);
        assert_eq!(cell.load(), LifecycleState::Stopped);
    }
}