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 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    /// Canonical wire label stamped into the
187    /// [`crate::annotations::RELEASED_FROM`] annotation when a
188    /// Process transitions Attested/Failed → Releasing. Encodes the
189    /// terminal-reached-gate axis into the two labels
190    /// `handle_releasing`'s `advance_out_of_releasing` dispatch reads
191    /// back through [`Self::parse_released_from`]. Non-gate phases
192    /// (Running, Reconverging, etc.) collapse to `"Attested"` per the
193    /// forward-compat invariant `p_current_phase_str` promised
194    /// pre-lift, so an unexpected observed-phase never leaks into the
195    /// annotation as a Zombie-routing "Failed" label.
196    ///
197    /// Peer to [`Self::parse_released_from`] on the encoder/decoder
198    /// pair — a wire-format drift on `Failed`'s `as_str` reaches both
199    /// sites through the ONE substrate owner.
200    pub const fn released_from_label(self) -> &'static str {
201        match self {
202            Self::Failed => Self::Failed.as_str(),
203            _ => Self::Attested.as_str(),
204        }
205    }
206
207    /// Decode a [`crate::annotations::RELEASED_FROM`] annotation
208    /// value back to the terminal-reached gate the Process came
209    /// through. Mirror of [`Self::released_from_label`]: only the
210    /// exact string [`Self::Failed`]`.as_str()` decodes to `Failed`;
211    /// every other value (including `None`, an empty string, a
212    /// case-drifted "failed", a value from a future phase variant)
213    /// collapses to `Attested`, preserving `released_from_annotation`'s
214    /// pre-lift forward-compat semantics byte-for-byte.
215    ///
216    /// The two together form a total closed-set projection over
217    /// `{Attested, Failed}` — every input on both sides maps into
218    /// the gate set, and the composition
219    /// `parse_released_from(Some(p.released_from_label()))` is the
220    /// identity on `{Attested, Failed}` (pinned by
221    /// `released_from_label_and_parse_are_inverse_on_terminal_gates`).
222    pub fn parse_released_from(s: Option<&str>) -> Self {
223        match s {
224            Some(v) if v == Self::Failed.as_str() => Self::Failed,
225            _ => Self::Attested,
226        }
227    }
228
229    /// True if the phase transition `self → next` is legal.
230    pub const fn can_transition_to(self, next: Self) -> bool {
231        use ProcessPhase::*;
232        matches!(
233            (self, next),
234            (Pending, Forking)
235                | (Forking, Execing)
236                | (Execing, Running)
237                | (Execing, Failed)
238                | (Running, Attested)
239                | (Running, Exiting)
240                | (Running, Failed)
241                | (Running, Reconverging)
242                | (Attested, Reconverging)
243                | (Attested, Releasing)
244                | (Attested, Exiting)
245                | (Failed, Releasing)
246                | (Failed, Zombie)
247                | (Releasing, Exiting)
248                | (Releasing, Zombie)
249                | (Reconverging, Execing)
250                | (Exiting, Zombie)
251                | (Zombie, Reaped)
252        )
253    }
254}
255
256// `impl FromStr for ProcessPhase` +
257// `impl tatara_lisp::ClosedSet for ProcessPhase` +
258// `impl std::fmt::Display for ProcessPhase` +
259// `pub struct UnknownPhase(pub String)` are all generated by
260// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
261// `#[closed_set(via = "as_str", unknown = "UnknownPhase", display,
262// generate_unknown = "process phase")]` on the enum declaration
263// above. `label` delegates to the inherent `ProcessPhase::as_str`
264// — the inherent name (PascalCase `as_str`) stays the load-bearing
265// wire-vocabulary projection that matches the serde rename + the
266// CRD `enum:` enumeration verbatim, while generic `T: ClosedSet`
267// consumers reach the STABLE workspace-wide name (`label`). The
268// `display` flag emits the `f.write_str(self.as_str())` delegation
269// block at the same proc-macro site. The carrier is named
270// `UnknownPhase` (not the auto-derived `UnknownProcessPhase`)
271// because the short name is the published public-API surface every
272// downstream caller imports — `#[closed_set(unknown =
273// "UnknownPhase")]` pins it. The explicit `generate_unknown =
274// "process phase"` label overrides the auto-derived "process phase"
275// (which happens to match byte-for-byte — pinning it here keeps the
276// pre-lift wording stable against any future change to the
277// `pascal_to_spaced_lowercase` helper's behavior). Symmetric to
278// every other `#[derive(DeriveClosedSet)]` implementor across the
279// crate (`WorkloadKind`, `VerificationPhase`, `MustReachPhase`,
280// `SighupStrategy`, `TeardownPolicy`, `ConditionKind`, every
281// classification axis, every pool/export/allocation closed-set).
282
283#[cfg(test)]
284mod tests {
285    use super::ProcessPhase::*;
286
287    #[test]
288    fn canonical_path_is_legal() {
289        assert!(Pending.can_transition_to(Forking));
290        assert!(Forking.can_transition_to(Execing));
291        assert!(Execing.can_transition_to(Running));
292        assert!(Running.can_transition_to(Attested));
293        assert!(Attested.can_transition_to(Reconverging));
294        assert!(Reconverging.can_transition_to(Execing));
295        assert!(Attested.can_transition_to(Exiting));
296        assert!(Exiting.can_transition_to(Zombie));
297        assert!(Zombie.can_transition_to(Reaped));
298    }
299
300    /// Releasing path — Attested or Failed may detour through the
301    /// export window before terminating. Releasing is itself a
302    /// legal source for Exiting (happy path) or Zombie (export-
303    /// worker terminal-failure shortcut).
304    #[test]
305    fn releasing_path_is_legal() {
306        assert!(Attested.can_transition_to(Releasing));
307        assert!(Failed.can_transition_to(Releasing));
308        assert!(Releasing.can_transition_to(Exiting));
309        assert!(Releasing.can_transition_to(Zombie));
310        // Releasing is alive — children (export Jobs) still running.
311        assert!(Releasing.is_alive());
312        // Releasing is not a terminal-reached gate.
313        assert!(!Releasing.is_terminal_reached());
314    }
315
316    #[test]
317    fn terminal_reached_gates_are_attested_and_failed() {
318        assert!(Attested.is_terminal_reached());
319        assert!(Failed.is_terminal_reached());
320        // Sweep every other variant via ALL so a future variant is
321        // covered automatically (was a hand-maintained 9-entry array).
322        for p in super::ProcessPhase::ALL {
323            if matches!(p, Attested | Failed) {
324                continue;
325            }
326            assert!(!p.is_terminal_reached(), "{p:?} is not a terminal gate");
327        }
328    }
329
330    #[test]
331    fn releasing_can_only_be_entered_from_terminal_gates() {
332        // Releasing has exactly two legal entries — the terminal-
333        // reached gates. Anything else is a state-machine bug.
334        // ALL is the source of truth for the candidate set.
335        let entries: Vec<_> = super::ProcessPhase::ALL
336            .into_iter()
337            .filter(|p| p.can_transition_to(Releasing))
338            .collect();
339        assert_eq!(entries, vec![Attested, Failed]);
340    }
341
342    #[test]
343    fn reaped_is_sink() {
344        assert!(Reaped.is_terminal());
345        // Sweep every non-Reaped variant via ALL so a new phase
346        // pins the sink-ness invariant automatically.
347        for next in super::ProcessPhase::ALL {
348            if next == Reaped {
349                continue;
350            }
351            assert!(
352                !Reaped.can_transition_to(next),
353                "Reaped → {next:?} should be illegal"
354            );
355        }
356    }
357
358    #[test]
359    fn cannot_skip_forking() {
360        assert!(!Pending.can_transition_to(Execing));
361        assert!(!Pending.can_transition_to(Running));
362    }
363
364    #[test]
365    fn running_is_alive() {
366        assert!(Running.is_alive());
367        assert!(Attested.is_alive());
368        assert!(!Zombie.is_alive());
369        assert!(!Reaped.is_alive());
370    }
371
372    /// [`ProcessPhase::has_exited`] sinks to `{Failed, Zombie, Reaped}`
373    /// verbatim — pins the closed set the substrate's
374    /// [`crate::pool::PoolMemberSnapshot::is_failed`] alias delegates
375    /// to post-lift, so a variant added inside the exited partition
376    /// (a new terminal-error variant) is caught here rather than as
377    /// silent drift at the pool reconciler's health-count seed.
378    #[test]
379    fn has_exited_sinks_to_failed_zombie_reaped() {
380        for p in super::ProcessPhase::ALL {
381            let expected = matches!(p, Failed | Zombie | Reaped);
382            assert_eq!(
383                p.has_exited(),
384                expected,
385                "{p:?}.has_exited() should be {expected}"
386            );
387        }
388    }
389
390    /// [`ProcessPhase::has_exited`] IS the boolean complement of
391    /// [`ProcessPhase::is_alive`] across every variant — pinning the
392    /// closed-set complement invariant so a future rename of either
393    /// primitive that drifted one edge (e.g. a variant classified as
394    /// both alive AND exited, or as neither) surfaces here rather
395    /// than as an operator-facing pool-count skew where the same
396    /// Process is counted both toward the alive pool AND toward the
397    /// failed-reap queue.
398    #[test]
399    fn has_exited_is_complement_of_is_alive() {
400        for p in super::ProcessPhase::ALL {
401            assert_eq!(
402                p.has_exited(),
403                !p.is_alive(),
404                "{p:?}: has_exited should equal !is_alive"
405            );
406        }
407    }
408
409    /// The exited set and the [`ProcessPhase::is_running`] set are
410    /// disjoint — no Process is both "healthy" (Running or Attested)
411    /// and "exited" (Failed/Zombie/Reaped) at the same phase. Pins
412    /// the substrate invariant the pool reconciler's `is_healthy` +
413    /// `is_failed` snapshot predicates rely on to partition members
414    /// without double-counting.
415    #[test]
416    fn is_running_and_has_exited_are_disjoint() {
417        for p in super::ProcessPhase::ALL {
418            assert!(
419                !(p.is_running() && p.has_exited()),
420                "{p:?}: cannot be both is_running() and has_exited()"
421            );
422        }
423    }
424
425    // ── closed-set algebra contracts (ALL × as_str × FromStr) ────────
426
427    /// Structural well-formedness of [`ProcessPhase`] as a
428    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
429    /// testkit lift that pins all three structural invariants
430    /// (`ALL` is non-empty, every variant round-trips through
431    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
432    /// outside the closed set) at ONE call site. Replaces the
433    /// hand-derived `all_phases_roundtrip_via_as_str` +
434    /// `all_is_unique_and_complete` + the empty-input arm of the
435    /// per-implementor unknown-error test — those three sites
436    /// re-derived byte-for-byte across 36+ closed-set implementors
437    /// pre-lift; this helper lifts them all onto the trait so any
438    /// future closed-set implementor inherits the contract by
439    /// implementing the trait + calling this one helper, with no
440    /// HashSet sweep or `FromStr` round-trip loop to copy.
441    ///
442    /// `FromStr` delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
443    /// so this helper exercises the exact code path the operator hits
444    /// when parsing an annotation / status-field value back to the
445    /// typed phase.
446    #[test]
447    fn process_phase_is_well_formed_closed_set() {
448        tatara_closed_set::assert_closed_set_well_formed::<super::ProcessPhase>();
449    }
450
451    /// The Display impl IS `as_str` — pinning this lets future
452    /// callers reach for either projection without drift. If a
453    /// reviewer accidentally re-introduces an inline match in
454    /// Display, this test would fail the moment a variant rename
455    /// touches one site but not the other. NOT lifted into the
456    /// `ClosedSet` testkit because `Display` is a per-implementor
457    /// concern (the trait can't provide a default `Display` impl in
458    /// stable Rust) and the projection's choice (`as_str` vs.
459    /// inherent label vs. tagged-Debug) is domain-specific.
460    #[test]
461    fn display_matches_as_str() {
462        for phase in super::ProcessPhase::ALL {
463            assert_eq!(phase.to_string(), phase.as_str());
464        }
465    }
466
467    /// `FromStr` rejects domain-specific bad inputs — case-drifted /
468    /// typo / extinct-variant — and the error echoes the input
469    /// VERBATIM so the operator-facing diagnostic carries the
470    /// offending value, not a normalized form. Kept per-implementor
471    /// because the verbatim-payload contract is a property of the
472    /// per-enum `Unknown<X>(pub String)` newtype, not of the trait's
473    /// structural surface — the trait's `make_unknown(s: &str)`
474    /// hook lets a future implementor swap the carrier for a
475    /// structured diagnostic without changing the trait contract, so
476    /// the payload-echo invariant lives with the implementor that
477    /// chose the newtype shape. (The empty-input arm is now lifted
478    /// into `process_phase_is_well_formed_closed_set`; the
479    /// case-drifted / typo / extinct-variant arms stay here as
480    /// they're representative non-canonical inputs the operator
481    /// might supply.)
482    #[test]
483    fn unknown_phase_errors() {
484        use std::str::FromStr;
485        for bad in ["attested", "FAILED", "Cancelled", "Reapped"] {
486            let err = super::ProcessPhase::from_str(bad).unwrap_err();
487            assert_eq!(err.0, bad, "error payload should echo input verbatim");
488        }
489    }
490
491    // ─── released_from encoder/decoder substrate pins ────────────────
492    //
493    // Bind [`ProcessPhase::released_from_label`] +
494    // [`ProcessPhase::parse_released_from`] at fail-before-pass-after
495    // granularity so a regression that reshapes either side of the
496    // wire-format bijection over the terminal-reached-gate set
497    // {Attested, Failed} surfaces HERE rather than as silent skew
498    // between the phase machine's `p_current_phase_str` writer and
499    // its `released_from_annotation` reader — the two callsites the
500    // encoder/decoder pair collapses onto ONE substrate owner.
501    //
502    // Pre-lift both sites hand-authored a `match phase { Failed =>
503    // "Failed", _ => "Attested" }` / `match anno { Some("Failed") =>
504    // Failed, _ => Attested }` block with hardcoded string literals
505    // that did not go through `ProcessPhase::as_str`. A wire-format
506    // rename of the `Failed` or `Attested` variant that touched
507    // `as_str` alone would silently break the annotation contract at
508    // both callsites; post-lift the primitives route through
509    // `ProcessPhase::as_str` so the rename lands mechanically.
510
511    #[test]
512    fn released_from_label_maps_failed_to_failed_string() {
513        assert_eq!(super::ProcessPhase::Failed.released_from_label(), "Failed");
514    }
515
516    #[test]
517    fn released_from_label_maps_attested_to_attested_string() {
518        assert_eq!(
519            super::ProcessPhase::Attested.released_from_label(),
520            "Attested"
521        );
522    }
523
524    #[test]
525    fn released_from_label_collapses_non_gate_phases_to_attested() {
526        // Forward-compat pin: every non-{Attested,Failed} variant
527        // collapses to "Attested" — an unexpected observed-phase
528        // (Running, Reconverging, Zombie, …) leaking through to
529        // `p_current_phase_str`'s writer never stamps a Zombie-
530        // routing "Failed" label into the annotation. Sweep via ALL
531        // so a future variant is covered automatically.
532        for p in super::ProcessPhase::ALL {
533            if matches!(p, super::ProcessPhase::Failed) {
534                continue;
535            }
536            assert_eq!(
537                p.released_from_label(),
538                "Attested",
539                "{p:?} must collapse to \"Attested\" under released_from_label",
540            );
541        }
542    }
543
544    #[test]
545    fn parse_released_from_matches_hardcoded_pre_lift_reader() {
546        // Byte-for-byte parity witness against the pre-lift
547        // `phase_machine::released_from_annotation` match block:
548        //     match p.annotation(RELEASED_FROM) {
549        //         Some("Failed") => ProcessPhase::Failed,
550        //         _              => ProcessPhase::Attested,
551        //     }
552        // A regression that widened the "Failed" arm (a case-fold to
553        // Some("failed"), an alias like Some("FailedRun")), narrowed
554        // it (Some("Failed") + a trailing-newline gate), or drifted
555        // the default arm (a None-vs-Some("Attested") split) surfaces
556        // HERE.
557        assert_eq!(
558            super::ProcessPhase::parse_released_from(Some("Failed")),
559            super::ProcessPhase::Failed,
560        );
561        assert_eq!(
562            super::ProcessPhase::parse_released_from(Some("Attested")),
563            super::ProcessPhase::Attested,
564        );
565        assert_eq!(
566            super::ProcessPhase::parse_released_from(None),
567            super::ProcessPhase::Attested,
568        );
569        // Non-canonical inputs collapse to Attested — same forward-
570        // compat semantics the pre-lift `_` arm gave.
571        for bad in [
572            "",
573            "failed",
574            "FAILED",
575            "attested",
576            "Running",
577            "Reaped",
578            "Some(Failed)",
579        ] {
580            assert_eq!(
581                super::ProcessPhase::parse_released_from(Some(bad)),
582                super::ProcessPhase::Attested,
583                "non-canonical input {bad:?} must collapse to Attested",
584            );
585        }
586    }
587
588    #[test]
589    fn released_from_label_and_parse_are_inverse_on_terminal_gates() {
590        // The encoder/decoder pair round-trips exactly over
591        // {Attested, Failed} — the closed set the annotation is
592        // designed to carry. Sweep both gates; a regression that
593        // desynced the two projections (e.g. the encoder started
594        // stamping "attested" lowercase while the decoder still
595        // keyed on "Attested") would surface HERE.
596        for gate in [super::ProcessPhase::Attested, super::ProcessPhase::Failed] {
597            let round_trip =
598                super::ProcessPhase::parse_released_from(Some(gate.released_from_label()));
599            assert_eq!(
600                round_trip, gate,
601                "{gate:?} must round-trip through label→parse",
602            );
603        }
604    }
605
606    #[test]
607    fn released_from_label_routes_through_as_str_not_a_hardcoded_literal() {
608        // The primitive dispatches through `ProcessPhase::as_str` for
609        // both canonical labels — a future rename of either variant's
610        // wire form (an operator-facing normalization pass, a serde-
611        // rename attribute) that touched `as_str` alone would silently
612        // break the annotation contract if the encoder used hardcoded
613        // string literals. Witness the routing by matching the
614        // encoder's output against the corresponding variant's
615        // `as_str` projection.
616        assert_eq!(
617            super::ProcessPhase::Failed.released_from_label(),
618            super::ProcessPhase::Failed.as_str(),
619        );
620        assert_eq!(
621            super::ProcessPhase::Attested.released_from_label(),
622            super::ProcessPhase::Attested.as_str(),
623        );
624    }
625
626    #[test]
627    fn released_from_label_output_is_always_a_terminal_gate() {
628        // The encoder's codomain is exactly {Attested, Failed} — the
629        // two labels `advance_out_of_releasing`'s reader dispatches
630        // on. A regression that leaked a third label (e.g. the
631        // default arm returning "Unknown", or a new `Draining` gate
632        // added to the terminal-reached set producing its own label
633        // without a matching decoder arm) surfaces HERE.
634        for p in super::ProcessPhase::ALL {
635            let label = p.released_from_label();
636            let decoded = super::ProcessPhase::parse_released_from(Some(label));
637            assert!(
638                matches!(decoded, super::ProcessPhase::Attested | super::ProcessPhase::Failed),
639                "{p:?}'s label {label:?} decoded to {decoded:?} — must land in the {{Attested,Failed}} gate set",
640            );
641        }
642    }
643}