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}