Skip to main content

tatara_process/
phase.rs

1//! Unix process phases — authoritative state machine.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// The Unix-authentic phase a Process is in.
7///
8/// Canonical transitions:
9/// ```text
10/// Pending → Forking → Execing → Running → Attested
11///                                       ↘ Failed
12/// Attested → Reconverging → Execing                       (SIGHUP, no zombie)
13/// Attested → Releasing  → Exiting → Zombie → Reaped       (export-then-SIGTERM)
14/// Attested → Exiting    → Zombie → Reaped                 (no-exports SIGTERM)
15/// Failed   → Releasing  → Zombie → Reaped                 (post-mortem exports)
16/// Failed   → Zombie     → Reaped                          (no-exports failed)
17/// Running  → Exiting    → Zombie → Reaped                 (early SIGTERM, no exports)
18/// Running  → Failed                                       (non-zero exit)
19/// ```
20///
21/// `Releasing` is the export window — the reconciler runs declared
22/// `ExportSpec`s (via tatara-export-worker Jobs) between the
23/// terminal phase reached (`Attested` or `Failed`) and `Exiting` /
24/// `Zombie`. A Process with no `lifetime.ephemeral.exports`, or
25/// where no export's trigger matches the phase reached, skips
26/// `Releasing` entirely. See [`crate::export`] + [`crate::lifetime`].
27#[derive(
28    Clone,
29    Copy,
30    Debug,
31    PartialEq,
32    Eq,
33    Hash,
34    Serialize,
35    Deserialize,
36    JsonSchema,
37    tatara_closed_set::DeriveClosedSet,
38)]
39#[closed_set(
40    via = "as_str",
41    unknown = "UnknownPhase",
42    display,
43    generate_unknown = "process phase"
44)]
45pub enum ProcessPhase {
46    /// Admitted; PID not assigned yet.
47    Pending,
48    /// PID assigned in ProcessTable; parent linked; content hash computed.
49    Forking,
50    /// RENDER phase — evaluating Nix / expanding Lisp / rendering Helm;
51    /// emitting Kustomization + HelmRelease CRs.
52    Execing,
53    /// Flux resources applied; boundary preconditions being checked.
54    Running,
55    /// All postconditions hold; three-pillar attestation written.
56    Attested,
57    /// SIGHUP received or drift detected; returning to Execing.
58    Reconverging,
59    /// Export window — running declared `ExportSpec`s before SIGTERM.
60    /// Each export becomes a typed Job; the Process advances only
61    /// when every Job has reached a terminal state. Failures here
62    /// short-circuit straight to `Zombie` (the export attempt itself
63    /// is attested; partial-success is fine for best-effort channels).
64    Releasing,
65    /// SIGTERM received; graceful shutdown; children draining.
66    Exiting,
67    /// Exited non-zero; awaiting reap.
68    Failed,
69    /// Exited; children gone; finalizer not yet released.
70    Zombie,
71    /// Finalizer released; K8s GC will remove.
72    Reaped,
73}
74
75impl Default for ProcessPhase {
76    fn default() -> Self {
77        Self::Pending
78    }
79}
80
81impl ProcessPhase {
82    /// The closed set of phases — single source of truth that drives
83    /// `as_str` / Display / `FromStr` so adding a variant updates every
84    /// projection at once (and the `display_matches_as_str` +
85    /// `all_phases_roundtrip_via_as_str` tests pin the bridge). Also
86    /// used by the test sites that need to sweep every-other-variant
87    /// (`reaped_is_sink`, `releasing_can_only_be_entered_from_terminal_gates`,
88    /// `terminal_reached_gates_are_attested_and_failed`), so a new
89    /// variant lands in ALL once and reaches every test by iteration
90    /// rather than by per-test array maintenance.
91    pub const ALL: [Self; 11] = [
92        Self::Pending,
93        Self::Forking,
94        Self::Execing,
95        Self::Running,
96        Self::Attested,
97        Self::Reconverging,
98        Self::Releasing,
99        Self::Exiting,
100        Self::Failed,
101        Self::Zombie,
102        Self::Reaped,
103    ];
104
105    /// Canonical PascalCase wire-format projection. Used by Display
106    /// (single source of truth) and by `FromStr` to identify the
107    /// variant from its annotation / status-field representation.
108    /// The serde rename derives produce the same form on the JSON
109    /// boundary; this method exposes it to Rust callers (logs,
110    /// annotation values, error messages) without re-serializing.
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Pending => "Pending",
114            Self::Forking => "Forking",
115            Self::Execing => "Execing",
116            Self::Running => "Running",
117            Self::Attested => "Attested",
118            Self::Reconverging => "Reconverging",
119            Self::Releasing => "Releasing",
120            Self::Exiting => "Exiting",
121            Self::Failed => "Failed",
122            Self::Zombie => "Zombie",
123            Self::Reaped => "Reaped",
124        }
125    }
126
127    /// True if the phase is a terminal sink with no further transitions.
128    pub const fn is_terminal(self) -> bool {
129        matches!(self, Self::Reaped)
130    }
131
132    /// True if the process has reached a running state (Running or Attested).
133    pub const fn is_running(self) -> bool {
134        matches!(self, Self::Running | Self::Attested)
135    }
136
137    /// True if the process is still eligible to receive SIGHUP/SIGUSR* signals.
138    /// `Releasing` is alive — the Process hasn't been SIGTERM'd yet; its
139    /// children (export Jobs) are running.
140    pub const fn is_alive(self) -> bool {
141        !matches!(self, Self::Zombie | Self::Reaped | Self::Failed)
142    }
143
144    /// True if the phase is the export window — declared `ExportSpec`s
145    /// run here before SIGTERM. Reserved for the reconciler's
146    /// `handle_releasing` step + tatara-export-worker Job emission.
147    pub const fn is_releasing(self) -> bool {
148        matches!(self, Self::Releasing)
149    }
150
151    /// True if the phase is a terminal-reached gate (`Attested` or
152    /// `Failed`) — the points where the reconciler decides whether
153    /// to enter `Releasing`, jump straight to `Exiting`/`Zombie`, or
154    /// stay (for inspection per `TeardownPolicy`).
155    pub const fn is_terminal_reached(self) -> bool {
156        matches!(self, Self::Attested | Self::Failed)
157    }
158
159    /// True if the phase transition `self → next` is legal.
160    pub const fn can_transition_to(self, next: Self) -> bool {
161        use ProcessPhase::*;
162        matches!(
163            (self, next),
164            (Pending, Forking)
165                | (Forking, Execing)
166                | (Execing, Running)
167                | (Execing, Failed)
168                | (Running, Attested)
169                | (Running, Exiting)
170                | (Running, Failed)
171                | (Running, Reconverging)
172                | (Attested, Reconverging)
173                | (Attested, Releasing)
174                | (Attested, Exiting)
175                | (Failed, Releasing)
176                | (Failed, Zombie)
177                | (Releasing, Exiting)
178                | (Releasing, Zombie)
179                | (Reconverging, Execing)
180                | (Exiting, Zombie)
181                | (Zombie, Reaped)
182        )
183    }
184}
185
186// `impl FromStr for ProcessPhase` +
187// `impl tatara_lisp::ClosedSet for ProcessPhase` +
188// `impl std::fmt::Display for ProcessPhase` +
189// `pub struct UnknownPhase(pub String)` are all generated by
190// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
191// `#[closed_set(via = "as_str", unknown = "UnknownPhase", display,
192// generate_unknown = "process phase")]` on the enum declaration
193// above. `label` delegates to the inherent `ProcessPhase::as_str`
194// — the inherent name (PascalCase `as_str`) stays the load-bearing
195// wire-vocabulary projection that matches the serde rename + the
196// CRD `enum:` enumeration verbatim, while generic `T: ClosedSet`
197// consumers reach the STABLE workspace-wide name (`label`). The
198// `display` flag emits the `f.write_str(self.as_str())` delegation
199// block at the same proc-macro site. The carrier is named
200// `UnknownPhase` (not the auto-derived `UnknownProcessPhase`)
201// because the short name is the published public-API surface every
202// downstream caller imports — `#[closed_set(unknown =
203// "UnknownPhase")]` pins it. The explicit `generate_unknown =
204// "process phase"` label overrides the auto-derived "process phase"
205// (which happens to match byte-for-byte — pinning it here keeps the
206// pre-lift wording stable against any future change to the
207// `pascal_to_spaced_lowercase` helper's behavior). Symmetric to
208// every other `#[derive(DeriveClosedSet)]` implementor across the
209// crate (`WorkloadKind`, `VerificationPhase`, `MustReachPhase`,
210// `SighupStrategy`, `TeardownPolicy`, `ConditionKind`, every
211// classification axis, every pool/export/allocation closed-set).
212
213#[cfg(test)]
214mod tests {
215    use super::ProcessPhase::*;
216
217    #[test]
218    fn canonical_path_is_legal() {
219        assert!(Pending.can_transition_to(Forking));
220        assert!(Forking.can_transition_to(Execing));
221        assert!(Execing.can_transition_to(Running));
222        assert!(Running.can_transition_to(Attested));
223        assert!(Attested.can_transition_to(Reconverging));
224        assert!(Reconverging.can_transition_to(Execing));
225        assert!(Attested.can_transition_to(Exiting));
226        assert!(Exiting.can_transition_to(Zombie));
227        assert!(Zombie.can_transition_to(Reaped));
228    }
229
230    /// Releasing path — Attested or Failed may detour through the
231    /// export window before terminating. Releasing is itself a
232    /// legal source for Exiting (happy path) or Zombie (export-
233    /// worker terminal-failure shortcut).
234    #[test]
235    fn releasing_path_is_legal() {
236        assert!(Attested.can_transition_to(Releasing));
237        assert!(Failed.can_transition_to(Releasing));
238        assert!(Releasing.can_transition_to(Exiting));
239        assert!(Releasing.can_transition_to(Zombie));
240        // Releasing is alive — children (export Jobs) still running.
241        assert!(Releasing.is_alive());
242        // Releasing is not a terminal-reached gate.
243        assert!(!Releasing.is_terminal_reached());
244    }
245
246    #[test]
247    fn terminal_reached_gates_are_attested_and_failed() {
248        assert!(Attested.is_terminal_reached());
249        assert!(Failed.is_terminal_reached());
250        // Sweep every other variant via ALL so a future variant is
251        // covered automatically (was a hand-maintained 9-entry array).
252        for p in super::ProcessPhase::ALL {
253            if matches!(p, Attested | Failed) {
254                continue;
255            }
256            assert!(!p.is_terminal_reached(), "{p:?} is not a terminal gate");
257        }
258    }
259
260    #[test]
261    fn releasing_can_only_be_entered_from_terminal_gates() {
262        // Releasing has exactly two legal entries — the terminal-
263        // reached gates. Anything else is a state-machine bug.
264        // ALL is the source of truth for the candidate set.
265        let entries: Vec<_> = super::ProcessPhase::ALL
266            .into_iter()
267            .filter(|p| p.can_transition_to(Releasing))
268            .collect();
269        assert_eq!(entries, vec![Attested, Failed]);
270    }
271
272    #[test]
273    fn reaped_is_sink() {
274        assert!(Reaped.is_terminal());
275        // Sweep every non-Reaped variant via ALL so a new phase
276        // pins the sink-ness invariant automatically.
277        for next in super::ProcessPhase::ALL {
278            if next == Reaped {
279                continue;
280            }
281            assert!(
282                !Reaped.can_transition_to(next),
283                "Reaped → {next:?} should be illegal"
284            );
285        }
286    }
287
288    #[test]
289    fn cannot_skip_forking() {
290        assert!(!Pending.can_transition_to(Execing));
291        assert!(!Pending.can_transition_to(Running));
292    }
293
294    #[test]
295    fn running_is_alive() {
296        assert!(Running.is_alive());
297        assert!(Attested.is_alive());
298        assert!(!Zombie.is_alive());
299        assert!(!Reaped.is_alive());
300    }
301
302    // ── closed-set algebra contracts (ALL × as_str × FromStr) ────────
303
304    /// Structural well-formedness of [`ProcessPhase`] as a
305    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
306    /// testkit lift that pins all three structural invariants
307    /// (`ALL` is non-empty, every variant round-trips through
308    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
309    /// outside the closed set) at ONE call site. Replaces the
310    /// hand-derived `all_phases_roundtrip_via_as_str` +
311    /// `all_is_unique_and_complete` + the empty-input arm of the
312    /// per-implementor unknown-error test — those three sites
313    /// re-derived byte-for-byte across 36+ closed-set implementors
314    /// pre-lift; this helper lifts them all onto the trait so any
315    /// future closed-set implementor inherits the contract by
316    /// implementing the trait + calling this one helper, with no
317    /// HashSet sweep or `FromStr` round-trip loop to copy.
318    ///
319    /// `FromStr` delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
320    /// so this helper exercises the exact code path the operator hits
321    /// when parsing an annotation / status-field value back to the
322    /// typed phase.
323    #[test]
324    fn process_phase_is_well_formed_closed_set() {
325        tatara_closed_set::assert_closed_set_well_formed::<super::ProcessPhase>();
326    }
327
328    /// The Display impl IS `as_str` — pinning this lets future
329    /// callers reach for either projection without drift. If a
330    /// reviewer accidentally re-introduces an inline match in
331    /// Display, this test would fail the moment a variant rename
332    /// touches one site but not the other. NOT lifted into the
333    /// `ClosedSet` testkit because `Display` is a per-implementor
334    /// concern (the trait can't provide a default `Display` impl in
335    /// stable Rust) and the projection's choice (`as_str` vs.
336    /// inherent label vs. tagged-Debug) is domain-specific.
337    #[test]
338    fn display_matches_as_str() {
339        for phase in super::ProcessPhase::ALL {
340            assert_eq!(phase.to_string(), phase.as_str());
341        }
342    }
343
344    /// `FromStr` rejects domain-specific bad inputs — case-drifted /
345    /// typo / extinct-variant — and the error echoes the input
346    /// VERBATIM so the operator-facing diagnostic carries the
347    /// offending value, not a normalized form. Kept per-implementor
348    /// because the verbatim-payload contract is a property of the
349    /// per-enum `Unknown<X>(pub String)` newtype, not of the trait's
350    /// structural surface — the trait's `make_unknown(s: &str)`
351    /// hook lets a future implementor swap the carrier for a
352    /// structured diagnostic without changing the trait contract, so
353    /// the payload-echo invariant lives with the implementor that
354    /// chose the newtype shape. (The empty-input arm is now lifted
355    /// into `process_phase_is_well_formed_closed_set`; the
356    /// case-drifted / typo / extinct-variant arms stay here as
357    /// they're representative non-canonical inputs the operator
358    /// might supply.)
359    #[test]
360    fn unknown_phase_errors() {
361        use std::str::FromStr;
362        for bad in ["attested", "FAILED", "Cancelled", "Reapped"] {
363            let err = super::ProcessPhase::from_str(bad).unwrap_err();
364            assert_eq!(err.0, bad, "error payload should echo input verbatim");
365        }
366    }
367}