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 process has left the alive set — the closed-set
145 /// complement of [`Self::is_alive`]. Sinks to `Failed | Zombie |
146 /// Reaped`: the three phases where a Process is no longer
147 /// converging and its supervisor (pool reconciler, allocation
148 /// controller, cascade-delete GC) treats it as a terminated
149 /// member for reap / replace / status-count decisions. Named on
150 /// the "positive" pole so caller sites read as
151 /// `phase.has_exited()` instead of `!phase.is_alive()` — the
152 /// closed-set predicate family gains a symmetric member for the
153 /// same reason [`Self::is_running`] sits next to [`Self::is_alive`]
154 /// (both express live-set membership positively).
155 ///
156 /// Pre-lift the `Failed | Zombie | Reaped` set was hand-restated
157 /// as an inline `matches!(phase, Failed | Zombie | Reaped)` on
158 /// [`crate::pool::PoolMemberSnapshot::is_failed`] (peer to that
159 /// snapshot's `is_healthy` which restated the `Running | Attested`
160 /// set of [`Self::is_running`]). Both duplications now route
161 /// through their respective substrate closed-set predicate, so a
162 /// future variant added to the alive/dead partition (a new
163 /// `Draining` phase, a rename of `Zombie` → `Terminated`) lands
164 /// at the ONE closed-set surface here rather than as silent skew
165 /// between the substrate's `is_alive` and the pool reconciler's
166 /// downstream `is_failed` restatement.
167 pub const fn has_exited(self) -> bool {
168 !self.is_alive()
169 }
170
171 /// True if the phase is the export window — declared `ExportSpec`s
172 /// run here before SIGTERM. Reserved for the reconciler's
173 /// `handle_releasing` step + tatara-export-worker Job emission.
174 pub const fn is_releasing(self) -> bool {
175 matches!(self, Self::Releasing)
176 }
177
178 /// True if the phase is a terminal-reached gate (`Attested` or
179 /// `Failed`) — the points where the reconciler decides whether
180 /// to enter `Releasing`, jump straight to `Exiting`/`Zombie`, or
181 /// stay (for inspection per `TeardownPolicy`).
182 pub const fn is_terminal_reached(self) -> bool {
183 matches!(self, Self::Attested | Self::Failed)
184 }
185
186 /// True if the phase transition `self → next` is legal.
187 pub const fn can_transition_to(self, next: Self) -> bool {
188 use ProcessPhase::*;
189 matches!(
190 (self, next),
191 (Pending, Forking)
192 | (Forking, Execing)
193 | (Execing, Running)
194 | (Execing, Failed)
195 | (Running, Attested)
196 | (Running, Exiting)
197 | (Running, Failed)
198 | (Running, Reconverging)
199 | (Attested, Reconverging)
200 | (Attested, Releasing)
201 | (Attested, Exiting)
202 | (Failed, Releasing)
203 | (Failed, Zombie)
204 | (Releasing, Exiting)
205 | (Releasing, Zombie)
206 | (Reconverging, Execing)
207 | (Exiting, Zombie)
208 | (Zombie, Reaped)
209 )
210 }
211}
212
213// `impl FromStr for ProcessPhase` +
214// `impl tatara_lisp::ClosedSet for ProcessPhase` +
215// `impl std::fmt::Display for ProcessPhase` +
216// `pub struct UnknownPhase(pub String)` are all generated by
217// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
218// `#[closed_set(via = "as_str", unknown = "UnknownPhase", display,
219// generate_unknown = "process phase")]` on the enum declaration
220// above. `label` delegates to the inherent `ProcessPhase::as_str`
221// — the inherent name (PascalCase `as_str`) stays the load-bearing
222// wire-vocabulary projection that matches the serde rename + the
223// CRD `enum:` enumeration verbatim, while generic `T: ClosedSet`
224// consumers reach the STABLE workspace-wide name (`label`). The
225// `display` flag emits the `f.write_str(self.as_str())` delegation
226// block at the same proc-macro site. The carrier is named
227// `UnknownPhase` (not the auto-derived `UnknownProcessPhase`)
228// because the short name is the published public-API surface every
229// downstream caller imports — `#[closed_set(unknown =
230// "UnknownPhase")]` pins it. The explicit `generate_unknown =
231// "process phase"` label overrides the auto-derived "process phase"
232// (which happens to match byte-for-byte — pinning it here keeps the
233// pre-lift wording stable against any future change to the
234// `pascal_to_spaced_lowercase` helper's behavior). Symmetric to
235// every other `#[derive(DeriveClosedSet)]` implementor across the
236// crate (`WorkloadKind`, `VerificationPhase`, `MustReachPhase`,
237// `SighupStrategy`, `TeardownPolicy`, `ConditionKind`, every
238// classification axis, every pool/export/allocation closed-set).
239
240#[cfg(test)]
241mod tests {
242 use super::ProcessPhase::*;
243
244 #[test]
245 fn canonical_path_is_legal() {
246 assert!(Pending.can_transition_to(Forking));
247 assert!(Forking.can_transition_to(Execing));
248 assert!(Execing.can_transition_to(Running));
249 assert!(Running.can_transition_to(Attested));
250 assert!(Attested.can_transition_to(Reconverging));
251 assert!(Reconverging.can_transition_to(Execing));
252 assert!(Attested.can_transition_to(Exiting));
253 assert!(Exiting.can_transition_to(Zombie));
254 assert!(Zombie.can_transition_to(Reaped));
255 }
256
257 /// Releasing path — Attested or Failed may detour through the
258 /// export window before terminating. Releasing is itself a
259 /// legal source for Exiting (happy path) or Zombie (export-
260 /// worker terminal-failure shortcut).
261 #[test]
262 fn releasing_path_is_legal() {
263 assert!(Attested.can_transition_to(Releasing));
264 assert!(Failed.can_transition_to(Releasing));
265 assert!(Releasing.can_transition_to(Exiting));
266 assert!(Releasing.can_transition_to(Zombie));
267 // Releasing is alive — children (export Jobs) still running.
268 assert!(Releasing.is_alive());
269 // Releasing is not a terminal-reached gate.
270 assert!(!Releasing.is_terminal_reached());
271 }
272
273 #[test]
274 fn terminal_reached_gates_are_attested_and_failed() {
275 assert!(Attested.is_terminal_reached());
276 assert!(Failed.is_terminal_reached());
277 // Sweep every other variant via ALL so a future variant is
278 // covered automatically (was a hand-maintained 9-entry array).
279 for p in super::ProcessPhase::ALL {
280 if matches!(p, Attested | Failed) {
281 continue;
282 }
283 assert!(!p.is_terminal_reached(), "{p:?} is not a terminal gate");
284 }
285 }
286
287 #[test]
288 fn releasing_can_only_be_entered_from_terminal_gates() {
289 // Releasing has exactly two legal entries — the terminal-
290 // reached gates. Anything else is a state-machine bug.
291 // ALL is the source of truth for the candidate set.
292 let entries: Vec<_> = super::ProcessPhase::ALL
293 .into_iter()
294 .filter(|p| p.can_transition_to(Releasing))
295 .collect();
296 assert_eq!(entries, vec![Attested, Failed]);
297 }
298
299 #[test]
300 fn reaped_is_sink() {
301 assert!(Reaped.is_terminal());
302 // Sweep every non-Reaped variant via ALL so a new phase
303 // pins the sink-ness invariant automatically.
304 for next in super::ProcessPhase::ALL {
305 if next == Reaped {
306 continue;
307 }
308 assert!(
309 !Reaped.can_transition_to(next),
310 "Reaped → {next:?} should be illegal"
311 );
312 }
313 }
314
315 #[test]
316 fn cannot_skip_forking() {
317 assert!(!Pending.can_transition_to(Execing));
318 assert!(!Pending.can_transition_to(Running));
319 }
320
321 #[test]
322 fn running_is_alive() {
323 assert!(Running.is_alive());
324 assert!(Attested.is_alive());
325 assert!(!Zombie.is_alive());
326 assert!(!Reaped.is_alive());
327 }
328
329 /// [`ProcessPhase::has_exited`] sinks to `{Failed, Zombie, Reaped}`
330 /// verbatim — pins the closed set the substrate's
331 /// [`crate::pool::PoolMemberSnapshot::is_failed`] alias delegates
332 /// to post-lift, so a variant added inside the exited partition
333 /// (a new terminal-error variant) is caught here rather than as
334 /// silent drift at the pool reconciler's health-count seed.
335 #[test]
336 fn has_exited_sinks_to_failed_zombie_reaped() {
337 for p in super::ProcessPhase::ALL {
338 let expected = matches!(p, Failed | Zombie | Reaped);
339 assert_eq!(
340 p.has_exited(),
341 expected,
342 "{p:?}.has_exited() should be {expected}"
343 );
344 }
345 }
346
347 /// [`ProcessPhase::has_exited`] IS the boolean complement of
348 /// [`ProcessPhase::is_alive`] across every variant — pinning the
349 /// closed-set complement invariant so a future rename of either
350 /// primitive that drifted one edge (e.g. a variant classified as
351 /// both alive AND exited, or as neither) surfaces here rather
352 /// than as an operator-facing pool-count skew where the same
353 /// Process is counted both toward the alive pool AND toward the
354 /// failed-reap queue.
355 #[test]
356 fn has_exited_is_complement_of_is_alive() {
357 for p in super::ProcessPhase::ALL {
358 assert_eq!(
359 p.has_exited(),
360 !p.is_alive(),
361 "{p:?}: has_exited should equal !is_alive"
362 );
363 }
364 }
365
366 /// The exited set and the [`ProcessPhase::is_running`] set are
367 /// disjoint — no Process is both "healthy" (Running or Attested)
368 /// and "exited" (Failed/Zombie/Reaped) at the same phase. Pins
369 /// the substrate invariant the pool reconciler's `is_healthy` +
370 /// `is_failed` snapshot predicates rely on to partition members
371 /// without double-counting.
372 #[test]
373 fn is_running_and_has_exited_are_disjoint() {
374 for p in super::ProcessPhase::ALL {
375 assert!(
376 !(p.is_running() && p.has_exited()),
377 "{p:?}: cannot be both is_running() and has_exited()"
378 );
379 }
380 }
381
382 // ── closed-set algebra contracts (ALL × as_str × FromStr) ────────
383
384 /// Structural well-formedness of [`ProcessPhase`] as a
385 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
386 /// testkit lift that pins all three structural invariants
387 /// (`ALL` is non-empty, every variant round-trips through
388 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
389 /// outside the closed set) at ONE call site. Replaces the
390 /// hand-derived `all_phases_roundtrip_via_as_str` +
391 /// `all_is_unique_and_complete` + the empty-input arm of the
392 /// per-implementor unknown-error test — those three sites
393 /// re-derived byte-for-byte across 36+ closed-set implementors
394 /// pre-lift; this helper lifts them all onto the trait so any
395 /// future closed-set implementor inherits the contract by
396 /// implementing the trait + calling this one helper, with no
397 /// HashSet sweep or `FromStr` round-trip loop to copy.
398 ///
399 /// `FromStr` delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
400 /// so this helper exercises the exact code path the operator hits
401 /// when parsing an annotation / status-field value back to the
402 /// typed phase.
403 #[test]
404 fn process_phase_is_well_formed_closed_set() {
405 tatara_closed_set::assert_closed_set_well_formed::<super::ProcessPhase>();
406 }
407
408 /// The Display impl IS `as_str` — pinning this lets future
409 /// callers reach for either projection without drift. If a
410 /// reviewer accidentally re-introduces an inline match in
411 /// Display, this test would fail the moment a variant rename
412 /// touches one site but not the other. NOT lifted into the
413 /// `ClosedSet` testkit because `Display` is a per-implementor
414 /// concern (the trait can't provide a default `Display` impl in
415 /// stable Rust) and the projection's choice (`as_str` vs.
416 /// inherent label vs. tagged-Debug) is domain-specific.
417 #[test]
418 fn display_matches_as_str() {
419 for phase in super::ProcessPhase::ALL {
420 assert_eq!(phase.to_string(), phase.as_str());
421 }
422 }
423
424 /// `FromStr` rejects domain-specific bad inputs — case-drifted /
425 /// typo / extinct-variant — and the error echoes the input
426 /// VERBATIM so the operator-facing diagnostic carries the
427 /// offending value, not a normalized form. Kept per-implementor
428 /// because the verbatim-payload contract is a property of the
429 /// per-enum `Unknown<X>(pub String)` newtype, not of the trait's
430 /// structural surface — the trait's `make_unknown(s: &str)`
431 /// hook lets a future implementor swap the carrier for a
432 /// structured diagnostic without changing the trait contract, so
433 /// the payload-echo invariant lives with the implementor that
434 /// chose the newtype shape. (The empty-input arm is now lifted
435 /// into `process_phase_is_well_formed_closed_set`; the
436 /// case-drifted / typo / extinct-variant arms stay here as
437 /// they're representative non-canonical inputs the operator
438 /// might supply.)
439 #[test]
440 fn unknown_phase_errors() {
441 use std::str::FromStr;
442 for bad in ["attested", "FAILED", "Cancelled", "Reapped"] {
443 let err = super::ProcessPhase::from_str(bad).unwrap_err();
444 assert_eq!(err.0, bad, "error payload should echo input verbatim");
445 }
446 }
447}