Skip to main content

tatara_process/
lifetime.rs

1//! Process lifetime — Permanent (re-converging) vs Ephemeral (auto-SIGTERM
2//! on Attested / TTL / Failed).
3//!
4//! The wire shape follows the same "exactly-one-optional-field" pattern as
5//! `Intent` — one tagged-union idiom across the typescape.
6//!
7//! Lisp authoring:
8//! ```lisp
9//! :lifetime (:permanent)
10//! :lifetime (:ephemeral :ttl "1h"
11//!                       :teardown OnAttested
12//!                       :max-concurrent 1)
13//! ```
14//!
15//! Default = `Permanent` — every existing Process keeps its current behavior.
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::export::ExportSpec;
21use crate::phase::ProcessPhase;
22
23/// Lifetime slot on `ProcessSpec`. Exactly one variant should be populated;
24/// when both are unset the resolver returns `Permanent`.
25#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct Lifetime {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub permanent: Option<PermanentLifetime>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub ephemeral: Option<EphemeralLifetime>,
32}
33
34/// Resolved enum view used by the reconciler.
35#[derive(Clone, Debug)]
36pub enum LifetimeVariant<'a> {
37    Permanent(&'a PermanentLifetime),
38    Ephemeral(&'a EphemeralLifetime),
39}
40
41impl LifetimeVariant<'_> {
42    /// Reverse projection — every borrowed variant knows its
43    /// `LifetimeKind` discriminator. Pairs with `LifetimeKind::select`
44    /// so `LifetimeKind::select(lifetime).map(|v| v.kind())` round-trips
45    /// the closed set on the populated side; pinned by
46    /// `lifetime_kind_round_trips_through_variant_kind` locally + the
47    /// substrate trait [`crate::tagged_union::VariantKind`] shared with
48    /// every sibling borrowed-view enum. The impl below delegates to
49    /// this body as the ground-truth arm-to-Kind mapping.
50    pub fn kind(&self) -> LifetimeKind {
51        match self {
52            Self::Permanent(_) => LifetimeKind::Permanent,
53            Self::Ephemeral(_) => LifetimeKind::Ephemeral,
54        }
55    }
56
57    /// Projection to the inner `EphemeralLifetime` iff this variant is
58    /// `Ephemeral`. ONE site owns the "give me only the ephemeral case"
59    /// shape every consumer of the lifetime clock previously hand-rolled
60    /// via `let Ok(LifetimeVariant::Ephemeral(e)) = ...`; pinned by
61    /// `lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral`.
62    pub fn as_ephemeral(&self) -> Option<&EphemeralLifetime> {
63        match self {
64            Self::Ephemeral(e) => Some(e),
65            Self::Permanent(_) => None,
66        }
67    }
68
69    /// Projection to the inner `PermanentLifetime` iff this variant is
70    /// `Permanent`. Symmetric counterpart to [`Self::as_ephemeral`].
71    pub fn as_permanent(&self) -> Option<&PermanentLifetime> {
72        match self {
73            Self::Permanent(p) => Some(p),
74            Self::Ephemeral(_) => None,
75        }
76    }
77}
78
79impl crate::tagged_union::VariantKind<LifetimeKind> for LifetimeVariant<'_> {
80    fn variant_kind(&self) -> LifetimeKind {
81        self.kind()
82    }
83}
84
85/// Closed-set discriminator over `Lifetime`'s two tagged-union slots.
86/// Single source of truth that drives `Lifetime::variant`'s ambiguity
87/// resolver, the reverse `LifetimeVariant::kind` projection, and any
88/// `select`-style routing. Adding a third lifetime variant (e.g. a
89/// future `Burst` slot for budget-capped non-TTL lifetimes) lands at
90/// one `ALL` entry + one `as_str` arm + one `select` arm + one
91/// `LifetimeVariant::kind` arm — exhaustively checked by the compiler.
92///
93/// Sibling closed-set lift to [`crate::intent::IntentKind`] on the
94/// same `ProcessSpec` axis. Same shape, smaller closed set, same
95/// compounding pattern. Adopts `#[derive(DeriveClosedSet)]` +
96/// `#[closed_set(via = "as_str", generate_unknown, display)]` so
97/// [`tatara_closed_set::ClosedSet`], [`std::fmt::Display`],
98/// [`std::str::FromStr`], and the [`UnknownLifetimeKind`] carrier
99/// all emerge from ONE derive on the substrate-wide shape every
100/// sibling closed-set discriminator across the crate publishes —
101/// no hand-rolled `impl` blocks, no drift-risk between the four
102/// projections. The parent `Lifetime` doesn't impl
103/// [`crate::tagged_union::TaggedUnion`] (empty resolves to
104/// `Permanent(&DEFAULT_PERMANENT)`, not to an error), so the
105/// `TaggedUnion`-bound substrate primitives don't reach it; the
106/// closed-set-bound peer
107/// [`crate::tagged_union::assert_wire_key_matches_label`] does.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
109#[closed_set(via = "as_str", generate_unknown, display)]
110pub enum LifetimeKind {
111    Permanent,
112    Ephemeral,
113}
114
115impl LifetimeKind {
116    /// The closed set of lifetime kinds — single source of truth that
117    /// drives `Lifetime::variant`'s sweep so a variant added without
118    /// an `ALL` entry never reaches the resolver.
119    pub const ALL: [Self; 2] = [Self::Permanent, Self::Ephemeral];
120
121    /// Canonical lower-case wire-format key — matches the serde
122    /// `rename_all = "camelCase"` field name on `Lifetime`. Pinned by
123    /// `lifetime_kind_as_str_matches_lifetime_field_name`.
124    pub const fn as_str(self) -> &'static str {
125        match self {
126            Self::Permanent => "permanent",
127            Self::Ephemeral => "ephemeral",
128        }
129    }
130
131    /// Project a `Lifetime` borrow into the optional typed variant view
132    /// for this kind. Returns `None` iff the matching slot is `None`.
133    /// Composes the closed-set sweep `Lifetime::variant` loops over.
134    pub fn select<'a>(self, lifetime: &'a Lifetime) -> Option<LifetimeVariant<'a>> {
135        match self {
136            Self::Permanent => lifetime.permanent.as_ref().map(LifetimeVariant::Permanent),
137            Self::Ephemeral => lifetime.ephemeral.as_ref().map(LifetimeVariant::Ephemeral),
138        }
139    }
140}
141
142#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
143pub enum LifetimeError {
144    #[error("lifetime has multiple variants set; at most one required")]
145    Ambiguous,
146}
147
148impl Lifetime {
149    /// True when no variant is set — treated as `Permanent` by the resolver.
150    pub fn is_default(&self) -> bool {
151        self.permanent.is_none() && self.ephemeral.is_none()
152    }
153
154    /// Resolve to a variant view. Empty resolves to `Permanent` (a static
155    /// borrow on the embedded `DEFAULT_PERMANENT`); ambiguous (both set) is
156    /// an error.
157    ///
158    /// Sweeps over `LifetimeKind::ALL` so a third variant added with an
159    /// `ALL` entry is structurally honored at this site — no parallel
160    /// `is_some()` count, no per-variant if-let chain.
161    pub fn variant(&self) -> Result<LifetimeVariant<'_>, LifetimeError> {
162        use crate::tagged_union::{resolve, ResolveError};
163        match resolve(LifetimeKind::ALL.into_iter().map(|k| k.select(self))) {
164            Ok(v) => Ok(v),
165            Err(ResolveError::None) => Ok(LifetimeVariant::Permanent(&DEFAULT_PERMANENT)),
166            Err(ResolveError::Many) => Err(LifetimeError::Ambiguous),
167        }
168    }
169
170    /// True iff `ephemeral` is set.
171    pub fn is_ephemeral(&self) -> bool {
172        self.ephemeral.is_some()
173    }
174
175    /// Compound projection: `Some(&e)` iff [`Self::variant`] resolves
176    /// unambiguously to `Ephemeral(e)`; `None` for every other outcome
177    /// (empty → `Permanent` default, `Permanent` slot only, or
178    /// [`LifetimeError::Ambiguous`] when BOTH slots are set).
179    ///
180    /// The ambiguous case is deliberately collapsed to `None`: an
181    /// operator-authored spec with both `permanent:` and `ephemeral:`
182    /// populated is a mis-configuration, and every production consumer
183    /// of the pair [`crate::lifetime_clock::evaluate`] +
184    /// [`crate::lifetime_clock::requeue_with_ttl`] previously
185    /// hand-rolled the SAME two-step projection
186    /// (`variant().ok()?.as_ephemeral()`) whose Err-arm and
187    /// Permanent-arm both fell through to the same "no ephemeral
188    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
189    /// Lifting that chained collapse to ONE substrate primitive puts
190    /// "the ephemeral spec now, iff the resolver picked it" behind a
191    /// single call site and closes the possibility of a per-consumer
192    /// drift where one branch honors ambiguity and the other doesn't.
193    ///
194    /// A future third variant added to `Lifetime` (e.g. `Burst` for
195    /// budget-capped non-TTL lifetimes) reaches this projection
196    /// through the SAME [`Self::variant`] resolver + the SAME
197    /// [`LifetimeVariant::as_ephemeral`] discriminator, so the
198    /// ephemeral-only projection stays intact without a new arm here.
199    ///
200    /// Pinned by
201    /// `resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot`.
202    pub fn resolved_ephemeral(&self) -> Option<&EphemeralLifetime> {
203        // Pattern-match on the owned `LifetimeVariant` (not
204        // `variant.as_ephemeral()`) so the returned borrow carries the
205        // resolver's `'_self` lifetime through directly instead of the
206        // shorter borrow `as_ephemeral(&self)` synthesizes on the
207        // temporary variant. Symmetric peer discriminator arm
208        // `LifetimeVariant::as_ephemeral` still owns the closed-set
209        // projection for consumers that hold the variant by borrow;
210        // this projection is the compound-lift entry point for
211        // consumers whose call graph starts from `&Lifetime`.
212        match self.variant().ok()? {
213            LifetimeVariant::Ephemeral(e) => Some(e),
214            LifetimeVariant::Permanent(_) => None,
215        }
216    }
217}
218
219const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
220
221/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
222/// SIGTERM terminates only on explicit operator action.
223#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
224#[serde(rename_all = "camelCase")]
225pub struct PermanentLifetime {}
226
227/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
228///
229/// Phase semantics:
230/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
231///   reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
232/// - On `Failed`  with `teardown_policy ∈ {OnFailed,   Always}`:
233///   same. Otherwise Process stays at Failed for forensic inspection.
234/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
235///   reconcile loop tick. TTL expiry while in any non-terminal phase
236///   forces SIGTERM regardless of `teardown_policy`.
237#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
238#[serde(rename_all = "camelCase")]
239pub struct EphemeralLifetime {
240    /// `humantime`-parseable duration from `phaseSince(Forking)` after
241    /// which the Process is force-SIGTERM'd.
242    #[serde(default = "default_ttl")]
243    pub ttl: String,
244
245    /// When the Process auto-terminates.
246    #[serde(default)]
247    pub teardown_policy: TeardownPolicy,
248
249    /// Cluster-wide concurrency budget across ephemeral Processes that
250    /// share the same `spec.identity.name_override` / chart_ref.
251    /// `0` = no cap. Enforced by the reconciler before transitioning out
252    /// of `Pending`.
253    #[serde(default = "default_max_concurrent")]
254    pub max_concurrent: u32,
255
256    /// Declared exports — what artifacts survive teardown and where
257    /// they flow. Empty (default) = nothing survives, matching the
258    /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
259    /// independently triggered during the reconciler's `Releasing`
260    /// phase against the terminal `ProcessPhase` reached.
261    ///
262    /// See [`crate::export`] for the full type. All exports flow
263    /// through the pleme-io Vector + NATS layer — there is no
264    /// per-spec ad-hoc sink.
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub exports: Vec<ExportSpec>,
267}
268
269impl EphemeralLifetime {
270    /// The [`humantime`]-parsed `self.ttl` duration, or `None` if the
271    /// operator-authored `ttl` string doesn't parse — the one-line
272    /// collapse of the `humantime::parse_duration(&<eph>.ttl).ok()`
273    /// chain lifted to ONE typed owner past the ★★ PRIME-DIRECTIVE
274    /// ≥ 2 duplication threshold.
275    ///
276    /// Pre-lift the SAME chain was hand-authored at TWO workspace-wide
277    /// consumer sites in [`crate::lifetime_clock`], both walking
278    /// `humantime::parse_duration(&<ephemeral>.ttl)` on an
279    /// `&EphemeralLifetime` and discarding the parse-error arm to the
280    /// downstream "skip the timed decision" branch:
281    ///
282    /// * [`crate::lifetime_clock::evaluate`] — the TTL-expiry gate.
283    ///   Reads `if let Ok(ttl) = humantime::parse_duration(&ephemeral
284    ///   .ttl) { … }` inside the non-terminal-phase guard, comparing
285    ///   the parsed `Duration` against the wall-clock elapsed distance
286    ///   from `metadata.creation_timestamp` to fire
287    ///   `AutoTerminate::Now { TtlExpired }`.
288    /// * [`crate::lifetime_clock::requeue_with_ttl`] — the sleep-
289    ///   budget picker for the reconciler's next requeue. Reads
290    ///   `let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
291    ///   return default; };` and short-circuits to the caller's
292    ///   `default` sleep budget on parse failure.
293    ///
294    /// Both sites walked the SAME `humantime::parse_duration(&<eph>
295    /// .ttl)` chain and both wanted the Option-shape (the `Ok` arm as
296    /// the parsed `Duration`, the `Err` arm collapsed to the
297    /// downstream skip-branch). Post-lift each caller reaches for
298    /// `<eph>.ttl_duration()` and applies its own tail at its own
299    /// site (`if let Some(ttl) = …` for the guard, `let Some(ttl) =
300    /// … else { return default; }` for the sleep-budget picker).
301    ///
302    /// Return-form axis: `Option<std::time::Duration>` matches the
303    /// downstream comparator's type. The peer projection
304    /// [`crate::time::elapsed_since`] returns the SAME
305    /// `Option<std::time::Duration>` shape, so the TTL-expiry gate's
306    /// `elapsed >= ttl` comparator and the sleep-budget picker's
307    /// `ttl.checked_sub(elapsed)` subtraction each land with both
308    /// operands on the same axis, no per-consumer conversion.
309    ///
310    /// The `None` arm is the "operator's ttl string doesn't parse"
311    /// corner — a typo (`"1our"`), an unsupported unit, a
312    /// non-humantime literal that reached the field. Every consumer
313    /// interprets the corner as "no ttl data → don't fire the timed
314    /// decision" — [`crate::lifetime_clock::evaluate`] skips the
315    /// `AutoTerminate::Now` branch, [`crate::lifetime_clock::
316    /// requeue_with_ttl`] returns the caller's `default` sleep
317    /// budget. The pins below bind that shape.
318    ///
319    /// A future normalization (a per-fleet minimum TTL floor before
320    /// the humantime cast, a canonical unit-normalization pass, a
321    /// warn-log on unparseable strings) lands at THIS ONE substrate
322    /// primitive and every downstream ephemeral-TTL consumer inherits
323    /// the upgrade mechanically — no per-site edit at either of the
324    /// TWO listed callers or at future consumers (an allocation-TTL
325    /// remaining-budget picker, a pool free-TTL floor gate, a
326    /// stable-name claim-arbiter max-age tie-break).
327    ///
328    /// Sibling substrate primitive on the same
329    /// `(humantime string × Option<Duration>) → Option<Duration>`
330    /// axis: [`crate::time::elapsed_since`] — the `(now, anchor) →
331    /// Option<Duration>` peer that every timed-decision gate
332    /// composes with THIS primitive to produce an `elapsed >= ttl` /
333    /// `ttl.checked_sub(elapsed)` comparison.
334    ///
335    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
336    /// the `humantime::parse_duration(&<eph>.ttl).ok()` chain recurred
337    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
338    /// duplication trigger, and is lifted to ONE owner here).
339    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
340    /// the pins bind the parse-failure corner AND the empty-ttl corner
341    /// AND the humantime edge shapes AND the return-form parity with
342    /// [`crate::time::elapsed_since`], so a regression that drifts any
343    /// surface fails at `tests::ttl_duration_*` here rather than as
344    /// silent operator-facing skew between the TTL-expiry gate and
345    /// the sleep-budget picker on the SAME EphemeralLifetime).
346    #[must_use]
347    pub fn ttl_duration(&self) -> Option<std::time::Duration> {
348        humantime::parse_duration(&self.ttl).ok()
349    }
350
351    /// True iff any declared export's [`crate::export::ExportTrigger`]
352    /// fires for the given terminal-reached phase. The reconciler
353    /// uses this to decide whether to route `Attested`/`Failed`
354    /// through `Releasing` (the export window) or skip straight to
355    /// `Exiting`/`Zombie`.
356    ///
357    /// Returns `false` when the export list is empty or no trigger
358    /// matches — both cases collapse to the existing teardown path.
359    pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
360        self.exports.iter().any(|e| e.when.fires_on(phase))
361    }
362
363    /// Iterate over the exports whose trigger fires on `phase`.
364    /// The reconciler's `handle_releasing` consumes this to emit
365    /// one tatara-export-worker Job per surviving spec.
366    pub fn applicable_exports(
367        &self,
368        phase: ProcessPhase,
369    ) -> impl Iterator<Item = &ExportSpec> + '_ {
370        self.exports.iter().filter(move |e| e.when.fires_on(phase))
371    }
372}
373
374impl Default for EphemeralLifetime {
375    fn default() -> Self {
376        Self {
377            ttl: default_ttl(),
378            teardown_policy: TeardownPolicy::default(),
379            max_concurrent: default_max_concurrent(),
380            exports: Vec::new(),
381        }
382    }
383}
384
385fn default_ttl() -> String {
386    "1h".to_string()
387}
388fn default_max_concurrent() -> u32 {
389    1
390}
391
392/// When an ephemeral Process self-terminates.
393///
394/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
395/// foreign success/failure language — typed phases are the source of truth.
396#[derive(
397    Clone,
398    Copy,
399    Debug,
400    PartialEq,
401    Eq,
402    Hash,
403    Serialize,
404    Deserialize,
405    JsonSchema,
406    Default,
407    tatara_closed_set::DeriveClosedSet,
408)]
409#[serde(rename_all = "PascalCase")]
410#[closed_set(via = "as_str", display, generate_unknown)]
411pub enum TeardownPolicy {
412    /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
413    #[default]
414    Always,
415    /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
416    OnAttested,
417    /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
418    /// TTL or explicit operator SIGTERM.
419    OnFailed,
420    /// Never auto-terminate (TTL still applies).
421    Never,
422}
423
424impl TeardownPolicy {
425    /// The closed set of teardown policies — single source of truth that
426    /// drives the `as_str` / Display / `FromStr` triad and the typed
427    /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
428    /// variant lands at one `ALL` entry + one `as_str` arm + one
429    /// `should_teardown_on` arm — exhaustively checked by the compiler
430    /// (the `[Self; 4]` array literal forces the arity).
431    ///
432    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
433    /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
434    /// [`crate::boundary::ConditionKind::ALL`],
435    /// [`crate::phase::ProcessPhase::ALL`],
436    /// [`crate::signal::ProcessSignal::ALL`].
437    pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
438
439    /// Canonical PascalCase wire-format projection — matches the serde
440    /// `rename_all = "PascalCase"` output verbatim. Used by Display
441    /// (single source of truth), by `FromStr` to identify the variant
442    /// from its annotation / status-field representation, and by
443    /// operator-facing reason strings the reconciler stamps without
444    /// reaching for `{:?}` Debug formatting. Pinned by
445    /// `teardown_policy_as_str_matches_serde`.
446    pub const fn as_str(self) -> &'static str {
447        match self {
448            Self::Always => "Always",
449            Self::OnAttested => "OnAttested",
450            Self::OnFailed => "OnFailed",
451            Self::Never => "Never",
452        }
453    }
454
455    /// True iff, given a `ProcessPhase`, this policy says "tear down."
456    /// ONE typed dispatch over the typed phase enum that replaces the
457    /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
458    /// predicates `lifetime_clock::evaluate` previously branched on.
459    /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
460    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
461    /// always return `false` — teardown is a terminal-phase decision.
462    ///
463    /// The legacy [`Self::should_teardown_on_attested`] /
464    /// [`Self::should_teardown_on_failed`] predicates remain as thin
465    /// delegates so existing call sites keep their narrow signatures;
466    /// the truth table is pinned by
467    /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
468    pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
469        match phase {
470            ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
471            ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
472            ProcessPhase::Pending
473            | ProcessPhase::Forking
474            | ProcessPhase::Execing
475            | ProcessPhase::Running
476            | ProcessPhase::Reconverging
477            | ProcessPhase::Releasing
478            | ProcessPhase::Exiting
479            | ProcessPhase::Zombie
480            | ProcessPhase::Reaped => false,
481        }
482    }
483
484    /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
485    /// case — kept so existing call sites (notably the truth-table
486    /// test in this module) keep their narrow signature without
487    /// reaching for the typed-phase variant.
488    pub const fn should_teardown_on_attested(self) -> bool {
489        self.should_teardown_on(ProcessPhase::Attested)
490    }
491
492    /// Symmetric delegate to [`Self::should_teardown_on`] for the
493    /// `Failed` case.
494    pub const fn should_teardown_on_failed(self) -> bool {
495        self.should_teardown_on(ProcessPhase::Failed)
496    }
497}
498
499// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
500// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
501// `pub struct UnknownTeardownPolicy(pub String)` are generated by
502// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
503// "as_str", display, generate_unknown)]` on the enum declaration above.
504// The auto-derived label `"teardown policy"` matches the prior hand-
505// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
506// inherent `as_str` projection stays load-bearing — the PascalCase
507// wire-format that matches the serde rename + the reconciler's reason-
508// string emission verbatim — while the trait method `label` gives
509// generic consumers a STABLE name across the 36+ workspace-wide
510// closed-set implementors.
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn default_lifetime_resolves_to_permanent() {
518        let l = Lifetime::default();
519        assert!(l.is_default());
520        assert!(!l.is_ephemeral());
521        assert!(matches!(
522            l.variant().unwrap(),
523            LifetimeVariant::Permanent(_)
524        ));
525    }
526
527    #[test]
528    fn ephemeral_set_resolves() {
529        let l = Lifetime {
530            ephemeral: Some(EphemeralLifetime::default()),
531            ..Lifetime::default()
532        };
533        assert!(l.is_ephemeral());
534        match l.variant().unwrap() {
535            LifetimeVariant::Ephemeral(e) => {
536                assert_eq!(e.ttl, "1h");
537                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
538                assert_eq!(e.max_concurrent, 1);
539            }
540            other => panic!("expected ephemeral, got {other:?}"),
541        }
542    }
543
544    #[test]
545    fn ambiguous_lifetime_errors() {
546        let l = Lifetime {
547            permanent: Some(PermanentLifetime {}),
548            ephemeral: Some(EphemeralLifetime::default()),
549        };
550        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
551    }
552
553    #[test]
554    fn teardown_policy_dispatch() {
555        assert!(TeardownPolicy::Always.should_teardown_on_attested());
556        assert!(TeardownPolicy::Always.should_teardown_on_failed());
557        assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
558        assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
559        assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
560        assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
561        assert!(!TeardownPolicy::Never.should_teardown_on_attested());
562        assert!(!TeardownPolicy::Never.should_teardown_on_failed());
563    }
564
565    // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
566    //    should_teardown_on(phase)) ─
567
568    /// Structural well-formedness of [`TeardownPolicy`] as a
569    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
570    /// testkit lift that pins all three structural invariants (`ALL`
571    /// is non-empty, every variant round-trips through `label ↔
572    /// parse_label`, labels are pairwise distinct, `""` is outside the
573    /// closed set) at ONE call site. Replaces the hand-derived
574    /// `teardown_policy_all_is_unique_and_complete` +
575    /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
576    /// `unknown_teardown_policy_errors`. `FromStr` delegates to
577    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
578    /// exercises the same code path the reconciler hits when parsing a
579    /// CRD `enum:`-validated value back to the typed policy.
580    #[test]
581    fn teardown_policy_is_well_formed_closed_set() {
582        tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
583    }
584
585    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
586    /// output verbatim for every variant. A future variant rename
587    /// (or an `as_str` arm typo) lands here at one site. The reason
588    /// string `lifetime_clock::evaluate` stamps reaches for the same
589    /// projection via `Display`, so a Debug-vs-canonical drift would
590    /// surface here, not in operator-facing reason strings.
591    #[test]
592    fn teardown_policy_as_str_matches_serde() {
593        crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
594    }
595
596    /// The Display impl IS `as_str` — pinning this lets future
597    /// callers (notably `lifetime_clock::evaluate`'s reason string)
598    /// reach for either projection without drift.
599    #[test]
600    fn teardown_policy_display_matches_as_str() {
601        crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
602    }
603
604    /// `FromStr` rejects strings that aren't in the canonical
605    /// projection — lowercased / typo / unrelated — and the error
606    /// echoes the input verbatim so the operator-facing diagnostic
607    /// carries the offending value, not a normalized form. The
608    /// empty-input arm is pinned by
609    /// [`teardown_policy_is_well_formed_closed_set`] via the
610    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
611    /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
612    /// newtype, which the trait's `make_unknown` can't see.
613    #[test]
614    fn unknown_teardown_policy_errors() {
615        use std::str::FromStr;
616        for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
617            let err = TeardownPolicy::from_str(bad).unwrap_err();
618            assert_eq!(err.0, bad, "error payload should echo input verbatim");
619        }
620    }
621
622    /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
623    /// the documented (policy, phase) → bool table for every variant
624    /// at every typed phase. The two terminal phases (Attested,
625    /// Failed) carry the policy-specific result; every non-terminal
626    /// phase returns `false`. The closed-set sweep over both
627    /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
628    /// variant in either enum reaches this test by iteration — no
629    /// per-test array maintenance.
630    #[test]
631    fn teardown_policy_should_teardown_on_truth_table() {
632        for policy in TeardownPolicy::ALL {
633            for phase in ProcessPhase::ALL {
634                let expected = match phase {
635                    ProcessPhase::Attested => {
636                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
637                    }
638                    ProcessPhase::Failed => {
639                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
640                    }
641                    _ => false,
642                };
643                assert_eq!(
644                    policy.should_teardown_on(phase),
645                    expected,
646                    "should_teardown_on({policy:?}, {phase:?}) drift",
647                );
648            }
649        }
650    }
651
652    /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
653    /// `should_teardown_on_failed` predicates agree with the typed
654    /// `should_teardown_on(phase)` dispatch they delegate to, for
655    /// every variant. A regression that re-introduces an inline
656    /// `matches!` in either legacy predicate fails here the moment
657    /// `should_teardown_on` is the source of truth.
658    #[test]
659    fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
660        for policy in TeardownPolicy::ALL {
661            assert_eq!(
662                policy.should_teardown_on_attested(),
663                policy.should_teardown_on(ProcessPhase::Attested),
664                "Attested delegate drift for {policy:?}",
665            );
666            assert_eq!(
667                policy.should_teardown_on_failed(),
668                policy.should_teardown_on(ProcessPhase::Failed),
669                "Failed delegate drift for {policy:?}",
670            );
671        }
672    }
673
674    #[test]
675    fn serde_round_trip_ephemeral() {
676        let l = Lifetime {
677            ephemeral: Some(EphemeralLifetime {
678                ttl: "30m".into(),
679                teardown_policy: TeardownPolicy::OnAttested,
680                max_concurrent: 4,
681                exports: vec![],
682            }),
683            ..Lifetime::default()
684        };
685        let yaml = serde_yaml::to_string(&l).unwrap();
686        assert!(yaml.contains("ttl: 30m"));
687        assert!(yaml.contains("teardownPolicy: OnAttested"));
688        // Empty exports skip-serialize — explicit zero-trace default.
689        assert!(!yaml.contains("exports"));
690        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
691        assert!(back.is_ephemeral());
692        assert!(back.ephemeral.unwrap().exports.is_empty());
693    }
694
695    #[test]
696    fn applicable_exports_filters_by_trigger() {
697        use crate::export::{
698            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
699            VectorChannel,
700        };
701        let spec_attested = ExportSpec {
702            source: ArtifactSource {
703                receipts: Some(ReceiptsSource::default()),
704                ..ArtifactSource::default()
705            },
706            channel: VectorChannel {
707                http_event: Some(HttpEventChannel {
708                    endpoint: None,
709                    signal_type: "receipt".into(),
710                }),
711                ..VectorChannel::default()
712            },
713            when: ExportTrigger::OnAttested,
714            experiment_id_override: None,
715        };
716        let spec_failed = ExportSpec {
717            when: ExportTrigger::OnFailed,
718            ..spec_attested.clone()
719        };
720        let spec_always = ExportSpec {
721            when: ExportTrigger::Always,
722            ..spec_attested.clone()
723        };
724
725        let lt = EphemeralLifetime {
726            ttl: "1h".into(),
727            teardown_policy: TeardownPolicy::OnAttested,
728            max_concurrent: 1,
729            exports: vec![spec_attested, spec_failed, spec_always],
730        };
731
732        // Attested gate fires OnAttested + Always — 2 of 3.
733        assert!(lt.has_applicable_exports(ProcessPhase::Attested));
734        assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
735
736        // Failed gate fires OnFailed + Always — 2 of 3.
737        assert!(lt.has_applicable_exports(ProcessPhase::Failed));
738        assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
739
740        // Other phases never route through Releasing.
741        for p in [
742            ProcessPhase::Pending,
743            ProcessPhase::Forking,
744            ProcessPhase::Execing,
745            ProcessPhase::Running,
746            ProcessPhase::Reconverging,
747            ProcessPhase::Releasing,
748            ProcessPhase::Exiting,
749            ProcessPhase::Zombie,
750            ProcessPhase::Reaped,
751        ] {
752            assert!(!lt.has_applicable_exports(p));
753            assert_eq!(lt.applicable_exports(p).count(), 0);
754        }
755    }
756
757    #[test]
758    fn no_exports_means_no_applicable_exports() {
759        let lt = EphemeralLifetime::default();
760        assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
761        assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
762    }
763
764    /// Structural well-formedness of [`LifetimeKind`] as a
765    /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-
766    /// wide testkit that pins ALL structural invariants (`ALL` is
767    /// non-empty, every variant round-trips through `label ↔
768    /// parse_label`, labels are pairwise distinct, `""` is outside
769    /// the closed set, the [`UnknownLifetimeKind`] carrier's Display
770    /// renders the substrate-wide `"unknown lifetime kind: <input>"`
771    /// shape, `labels()` equals the natural `ALL × label` projection)
772    /// at ONE call site. Subsumes the hand-derived
773    /// `lifetime_kind_all_is_unique_and_complete` sweep the pre-derive
774    /// site published — clauses (1)+(3) of the testkit fold uniqueness
775    /// + non-emptiness into the substrate primitive's own body.
776    #[test]
777    fn lifetime_kind_is_well_formed_closed_set() {
778        tatara_closed_set::assert_closed_set_well_formed::<LifetimeKind>();
779    }
780
781    /// The Display impl IS `as_str` — pinning this lets future callers
782    /// reach for either projection without drift. Symmetric to every
783    /// sibling `X_display_matches_as_str` invariant across
784    /// `tatara-process`; routes through the substrate primitive
785    /// [`crate::tagged_union::assert_display_matches_label`] shared
786    /// with all 29+ production Display-alignment sites. The auto-
787    /// derived `Display` body from `#[closed_set(via = "as_str",
788    /// display)]` emits the substrate-wide `f.write_str(Self::as_str
789    /// (*self))` shape — a regression that regresses `as_str` (or a
790    /// future hand-rolled Display block that drifts from `as_str`)
791    /// surfaces here at the substrate-wide alignment probe.
792    #[test]
793    fn lifetime_kind_display_matches_as_str() {
794        crate::tagged_union::assert_display_matches_label::<LifetimeKind>();
795    }
796
797    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
798    /// camelCase serde field name on `Lifetime`. A future rename of
799    /// any field lands here at one site — and the wire-key alignment
800    /// stays coherent with the operator-facing serde shape.
801    ///
802    /// Routes through the substrate primitive
803    /// [`crate::tagged_union::assert_wire_key_matches_label`] — the
804    /// bound-relaxed peer of `assert_single_slot_key_matches_label`
805    /// that drops the `T: TaggedUnion` requirement so `Lifetime`
806    /// (whose empty variant resolves to `Permanent(&DEFAULT_PERMANENT)`
807    /// rather than to a [`crate::tagged_union::TaggedUnionError::empty`]
808    /// carrier) still binds through ONE substrate wire-key alignment
809    /// site. Pre-lift the body restated the same serialize +
810    /// exactly-one-key + name-equality sweep at this test surface
811    /// verbatim; post-lift the projection lives at ONE substrate
812    /// primitive and this site binds through a single call — the
813    /// same mechanical shape the four sibling TaggedUnion parents
814    /// carry via the trait-projected [`crate::tagged_union::assert_single_slot_key_matches_label`].
815    #[test]
816    fn lifetime_kind_as_str_matches_lifetime_field_name() {
817        crate::tagged_union::assert_wire_key_matches_label::<Lifetime, LifetimeKind, _>(
818            single_slot_lifetime,
819        );
820    }
821
822    /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
823    /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
824    /// projection composes the closed set in both directions — a
825    /// regression that misroutes a select arm (e.g. `Self::Permanent =>
826    /// l.ephemeral.as_ref()...`) fails loudly here.
827    #[test]
828    fn lifetime_kind_round_trips_through_variant_kind() {
829        for kind in LifetimeKind::ALL {
830            let l = single_slot_lifetime(kind);
831            let v = kind.select(&l).expect("populated slot must select");
832            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
833            // And the resolver lands on the same variant.
834            assert_eq!(
835                l.variant().expect("exactly-one variant").kind(),
836                kind,
837                "variant() resolver disagreed on {kind:?}"
838            );
839        }
840    }
841
842    /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
843    /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
844    /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
845    /// previously hand-rolled.
846    #[test]
847    fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
848        let permanent = PermanentLifetime {};
849        let v = LifetimeVariant::Permanent(&permanent);
850        assert!(v.as_ephemeral().is_none());
851        assert!(v.as_permanent().is_some());
852
853        let ephemeral = EphemeralLifetime {
854            ttl: "42m".into(),
855            teardown_policy: TeardownPolicy::OnAttested,
856            max_concurrent: 3,
857            exports: vec![],
858        };
859        let v = LifetimeVariant::Ephemeral(&ephemeral);
860        let inner = v.as_ephemeral().expect("ephemeral must project");
861        assert_eq!(inner.ttl, "42m");
862        assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
863        assert_eq!(inner.max_concurrent, 3);
864        assert!(v.as_permanent().is_none());
865    }
866
867    /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
868    /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
869    /// iff the resolver picks the ephemeral slot unambiguously. All
870    /// three failure modes (empty → permanent default, permanent-only,
871    /// ambiguous) collapse to `None`, matching the pre-lift
872    /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
873    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
874    ///
875    /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
876    /// permanent-only: an operator-authored spec with both slots
877    /// populated is a mis-configuration, and firing TTL / teardown on
878    /// it would be worse than skipping. Pinning that collapse here
879    /// closes the possibility of a future per-consumer drift where one
880    /// branch honors ambiguity (fires the timed action) and another
881    /// doesn't.
882    ///
883    /// The `Some` arm asserts byte-identity of the projected borrow
884    /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
885    /// silently swapped the projection to `self.permanent.as_ref()`
886    /// would surface here as a type mismatch rather than as a runtime
887    /// no-op in production.
888    #[test]
889    fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
890        // 1. Empty (both slots None) — resolves to Permanent default.
891        let l = Lifetime::default();
892        assert!(l.resolved_ephemeral().is_none());
893
894        // 2. Permanent-only.
895        let l = Lifetime {
896            permanent: Some(PermanentLifetime {}),
897            ..Lifetime::default()
898        };
899        assert!(l.resolved_ephemeral().is_none());
900
901        // 3. Ephemeral-only — the ONE arm that projects.
902        let ephemeral = EphemeralLifetime {
903            ttl: "13m".into(),
904            teardown_policy: TeardownPolicy::OnFailed,
905            max_concurrent: 7,
906            exports: vec![],
907        };
908        let l = Lifetime {
909            ephemeral: Some(ephemeral.clone()),
910            ..Lifetime::default()
911        };
912        let e = l.resolved_ephemeral().expect("ephemeral-only must project");
913        assert_eq!(e.ttl, "13m");
914        assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
915        assert_eq!(e.max_concurrent, 7);
916        // The borrow points into `self.ephemeral`, not into a temporary.
917        assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
918
919        // 4. Ambiguous (both slots set) — collapses to None, NOT to
920        //    the ephemeral inner. Guards against a future refactor
921        //    that silently unwrapped ambiguity to "prefer ephemeral".
922        let l = Lifetime {
923            permanent: Some(PermanentLifetime {}),
924            ephemeral: Some(EphemeralLifetime::default()),
925        };
926        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
927        assert!(l.resolved_ephemeral().is_none());
928    }
929
930    /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
931    /// set" outcome is `Permanent`, not an error. Pin via the
932    /// closed-set kind projection so a future variant added to the
933    /// closed set (and to the `Lifetime` struct) without updating
934    /// the default resolution would surface here — the default
935    /// stays `Permanent` regardless of the closed set's arity.
936    #[test]
937    fn empty_lifetime_resolves_to_permanent_kind() {
938        let l = Lifetime::default();
939        let v = l.variant().expect("default lifetime resolves");
940        assert_eq!(v.kind(), LifetimeKind::Permanent);
941        assert!(v.as_permanent().is_some());
942        assert!(v.as_ephemeral().is_none());
943    }
944
945    /// Construct a `Lifetime` with exactly the given kind's slot
946    /// populated by a minimal valid inner spec. Shared across the
947    /// closed-set property tests so they each cover every variant
948    /// without restating the construction table.
949    fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
950        match kind {
951            LifetimeKind::Permanent => Lifetime {
952                permanent: Some(PermanentLifetime {}),
953                ..Lifetime::default()
954            },
955            LifetimeKind::Ephemeral => Lifetime {
956                ephemeral: Some(EphemeralLifetime::default()),
957                ..Lifetime::default()
958            },
959        }
960    }
961
962    #[test]
963    fn exports_round_trip_through_lifetime() {
964        use crate::export::{
965            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
966            VectorChannel,
967        };
968        let l = Lifetime {
969            ephemeral: Some(EphemeralLifetime {
970                ttl: "30m".into(),
971                teardown_policy: TeardownPolicy::OnAttested,
972                max_concurrent: 1,
973                exports: vec![ExportSpec {
974                    source: ArtifactSource {
975                        receipts: Some(ReceiptsSource::default()),
976                        ..ArtifactSource::default()
977                    },
978                    channel: VectorChannel {
979                        http_event: Some(HttpEventChannel {
980                            endpoint: None,
981                            signal_type: "receipt".into(),
982                        }),
983                        ..VectorChannel::default()
984                    },
985                    when: ExportTrigger::OnAttested,
986                    experiment_id_override: None,
987                }],
988            }),
989            ..Lifetime::default()
990        };
991        let yaml = serde_yaml::to_string(&l).unwrap();
992        assert!(yaml.contains("exports:"));
993        assert!(yaml.contains("receipts: {}"));
994        assert!(yaml.contains("signalType: receipt"));
995        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
996        let e = back.ephemeral.unwrap();
997        assert_eq!(e.exports.len(), 1);
998        assert!(e.exports[0].source.receipts.is_some());
999        assert!(e.exports[0].channel.http_event.is_some());
1000    }
1001
1002    // ─── EphemeralLifetime::ttl_duration substrate pins ──────────────
1003    //
1004    // The `humantime::parse_duration(&<eph>.ttl).ok()` chain was open-
1005    // lifted from TWO consumer sites in `crate::lifetime_clock`
1006    // (`evaluate` + `requeue_with_ttl`) onto the ONE substrate
1007    // primitive [`EphemeralLifetime::ttl_duration`]. These pins bind
1008    // the primitive at the fail-before-pass-after level so a future
1009    // regression that swaps the return-form (an
1010    // `anyhow::Result<Duration>` — a per-consumer normalization gate
1011    // — a saturating `Duration::ZERO` for the parse-error corner)
1012    // fails HERE before landing at either consumer.
1013
1014    fn eph_with_ttl(ttl: &str) -> EphemeralLifetime {
1015        EphemeralLifetime {
1016            ttl: ttl.to_string(),
1017            teardown_policy: TeardownPolicy::default(),
1018            max_concurrent: 1,
1019            exports: Vec::new(),
1020        }
1021    }
1022
1023    /// The canonical shape every consumer rides through — a parseable
1024    /// humantime string projects to `Some(std::time::Duration)` matching
1025    /// the operator-authored `ttl` verbatim. Pin: the returned duration
1026    /// is EXACTLY what `humantime::parse_duration` produces for the
1027    /// same input, in `std::time::Duration` so the downstream
1028    /// `elapsed >= ttl` / `ttl.checked_sub(elapsed)` comparators land
1029    /// with both operands on the same axis without a per-consumer
1030    /// conversion.
1031    #[test]
1032    fn ttl_duration_parseable_humantime_projects_to_some() {
1033        for (ttl, expected) in [
1034            ("1h", std::time::Duration::from_secs(3600)),
1035            ("30m", std::time::Duration::from_secs(1800)),
1036            ("90s", std::time::Duration::from_secs(90)),
1037            ("5m30s", std::time::Duration::from_secs(330)),
1038            ("500ms", std::time::Duration::from_millis(500)),
1039        ] {
1040            assert_eq!(
1041                eph_with_ttl(ttl).ttl_duration(),
1042                Some(expected),
1043                "ttl_duration drift for {ttl:?}",
1044            );
1045        }
1046    }
1047
1048    /// The `None` arm is the "operator's ttl string doesn't parse"
1049    /// corner every consumer collapses to the skip-branch — a typo, an
1050    /// unsupported unit, an empty string, a free-form label that
1051    /// reached the field. Pin the boundary at the primitive so a
1052    /// future normalization can't silently substitute a default in
1053    /// place of the parse-failure signal.
1054    #[test]
1055    fn ttl_duration_unparseable_returns_none() {
1056        for bad in [
1057            // Empty string — the operator left the ttl blank.
1058            "", // Non-humantime literal — the operator wrote a foreign format.
1059            "forever", "1our",
1060            // Nonsense that looks numeric but isn't a humantime span.
1061            "abc",
1062            // Only-whitespace input — passes serde's non-empty gate but
1063            // doesn't parse as a duration.
1064            "   ",
1065        ] {
1066            assert_eq!(
1067                eph_with_ttl(bad).ttl_duration(),
1068                None,
1069                "ttl_duration should be None for {bad:?}",
1070            );
1071        }
1072    }
1073
1074    /// Zero-duration edge — `"0s"` parses to `Duration::ZERO`, not
1075    /// `None`. Every consumer needs the zero-ttl EphemeralLifetime to
1076    /// count as "elapsed=0 already ≥ ttl=0" so a zero-TTL ephemeral
1077    /// expires on its own creation instant; swapping this arm to
1078    /// `None` would silently keep every zero-TTL Process alive past
1079    /// the TTL-expiry gate in [`crate::lifetime_clock::evaluate`].
1080    #[test]
1081    fn ttl_duration_zero_seconds_returns_some_zero() {
1082        assert_eq!(
1083            eph_with_ttl("0s").ttl_duration(),
1084            Some(std::time::Duration::ZERO),
1085        );
1086    }
1087
1088    /// Subsecond precision survives the parse — a regression that
1089    /// silently truncated to whole seconds would compare
1090    /// `elapsed = 500ms` against a `ttl_duration()` of `500ms` as
1091    /// `500ms >= 0s` (always fire) rather than `500ms >= 500ms`
1092    /// (fires on the boundary). Peer to the sibling substrate
1093    /// `elapsed_since` subsecond pin in `crate::time`.
1094    #[test]
1095    fn ttl_duration_preserves_subsecond_precision() {
1096        assert_eq!(
1097            eph_with_ttl("250ms").ttl_duration(),
1098            Some(std::time::Duration::from_millis(250)),
1099        );
1100    }
1101
1102    /// Default `EphemeralLifetime` carries the canonical `"1h"` ttl
1103    /// (matches `default_ttl()` at this file's top), so
1104    /// `.ttl_duration()` on it agrees with a manually-parsed `"1h"`
1105    /// pass through `humantime`. Pins the default-ttl contract at
1106    /// the substrate so a future default rename lands at ONE site
1107    /// (this ttl_duration pin + the `default_ttl` fn) without silent
1108    /// wall-clock drift at either consumer.
1109    #[test]
1110    fn ttl_duration_of_default_ephemeral_matches_1h() {
1111        let e = EphemeralLifetime::default();
1112        assert_eq!(e.ttl, "1h");
1113        assert_eq!(e.ttl_duration(), Some(std::time::Duration::from_secs(3600)));
1114    }
1115
1116    /// Byte-for-byte parity with the pre-lift hand-authored chain —
1117    /// `<eph>.ttl_duration()` produces the SAME
1118    /// `Option<std::time::Duration>` as the two-link `humantime::
1119    /// parse_duration(&<eph>.ttl).ok()` chain both `lifetime_clock`
1120    /// consumers walked pre-lift. A regression at THIS pin fails
1121    /// before it lands at either consumer as silent operator-facing
1122    /// skew between the TTL-expiry gate and the sleep-budget picker.
1123    #[test]
1124    fn ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
1125        for ttl in [
1126            "1h", "30m", "90s", "5m30s", "500ms", "0s", "1us", "forever", "", "1our",
1127        ] {
1128            let e = eph_with_ttl(ttl);
1129            let via_primitive = e.ttl_duration();
1130            let hand_authored = humantime::parse_duration(&e.ttl).ok();
1131            assert_eq!(
1132                via_primitive, hand_authored,
1133                "ttl_duration must be byte-identical to `humantime::\
1134                 parse_duration(&self.ttl).ok()` for {ttl:?}",
1135            );
1136        }
1137    }
1138}