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}