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.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
97pub enum LifetimeKind {
98    Permanent,
99    Ephemeral,
100}
101
102impl LifetimeKind {
103    /// The closed set of lifetime kinds — single source of truth that
104    /// drives `Lifetime::variant`'s sweep so a variant added without
105    /// an `ALL` entry never reaches the resolver.
106    pub const ALL: [Self; 2] = [Self::Permanent, Self::Ephemeral];
107
108    /// Canonical lower-case wire-format key — matches the serde
109    /// `rename_all = "camelCase"` field name on `Lifetime`. Pinned by
110    /// `lifetime_kind_as_str_matches_lifetime_field_name`.
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Permanent => "permanent",
114            Self::Ephemeral => "ephemeral",
115        }
116    }
117
118    /// Project a `Lifetime` borrow into the optional typed variant view
119    /// for this kind. Returns `None` iff the matching slot is `None`.
120    /// Composes the closed-set sweep `Lifetime::variant` loops over.
121    pub fn select<'a>(self, lifetime: &'a Lifetime) -> Option<LifetimeVariant<'a>> {
122        match self {
123            Self::Permanent => lifetime.permanent.as_ref().map(LifetimeVariant::Permanent),
124            Self::Ephemeral => lifetime.ephemeral.as_ref().map(LifetimeVariant::Ephemeral),
125        }
126    }
127}
128
129#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
130pub enum LifetimeError {
131    #[error("lifetime has multiple variants set; at most one required")]
132    Ambiguous,
133}
134
135impl Lifetime {
136    /// True when no variant is set — treated as `Permanent` by the resolver.
137    pub fn is_default(&self) -> bool {
138        self.permanent.is_none() && self.ephemeral.is_none()
139    }
140
141    /// Resolve to a variant view. Empty resolves to `Permanent` (a static
142    /// borrow on the embedded `DEFAULT_PERMANENT`); ambiguous (both set) is
143    /// an error.
144    ///
145    /// Sweeps over `LifetimeKind::ALL` so a third variant added with an
146    /// `ALL` entry is structurally honored at this site — no parallel
147    /// `is_some()` count, no per-variant if-let chain.
148    pub fn variant(&self) -> Result<LifetimeVariant<'_>, LifetimeError> {
149        use crate::tagged_union::{resolve, ResolveError};
150        match resolve(LifetimeKind::ALL.into_iter().map(|k| k.select(self))) {
151            Ok(v) => Ok(v),
152            Err(ResolveError::None) => Ok(LifetimeVariant::Permanent(&DEFAULT_PERMANENT)),
153            Err(ResolveError::Many) => Err(LifetimeError::Ambiguous),
154        }
155    }
156
157    /// True iff `ephemeral` is set.
158    pub fn is_ephemeral(&self) -> bool {
159        self.ephemeral.is_some()
160    }
161
162    /// Compound projection: `Some(&e)` iff [`Self::variant`] resolves
163    /// unambiguously to `Ephemeral(e)`; `None` for every other outcome
164    /// (empty → `Permanent` default, `Permanent` slot only, or
165    /// [`LifetimeError::Ambiguous`] when BOTH slots are set).
166    ///
167    /// The ambiguous case is deliberately collapsed to `None`: an
168    /// operator-authored spec with both `permanent:` and `ephemeral:`
169    /// populated is a mis-configuration, and every production consumer
170    /// of the pair [`crate::lifetime_clock::evaluate`] +
171    /// [`crate::lifetime_clock::requeue_with_ttl`] previously
172    /// hand-rolled the SAME two-step projection
173    /// (`variant().ok()?.as_ephemeral()`) whose Err-arm and
174    /// Permanent-arm both fell through to the same "no ephemeral
175    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
176    /// Lifting that chained collapse to ONE substrate primitive puts
177    /// "the ephemeral spec now, iff the resolver picked it" behind a
178    /// single call site and closes the possibility of a per-consumer
179    /// drift where one branch honors ambiguity and the other doesn't.
180    ///
181    /// A future third variant added to `Lifetime` (e.g. `Burst` for
182    /// budget-capped non-TTL lifetimes) reaches this projection
183    /// through the SAME [`Self::variant`] resolver + the SAME
184    /// [`LifetimeVariant::as_ephemeral`] discriminator, so the
185    /// ephemeral-only projection stays intact without a new arm here.
186    ///
187    /// Pinned by
188    /// `resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot`.
189    pub fn resolved_ephemeral(&self) -> Option<&EphemeralLifetime> {
190        // Pattern-match on the owned `LifetimeVariant` (not
191        // `variant.as_ephemeral()`) so the returned borrow carries the
192        // resolver's `'_self` lifetime through directly instead of the
193        // shorter borrow `as_ephemeral(&self)` synthesizes on the
194        // temporary variant. Symmetric peer discriminator arm
195        // `LifetimeVariant::as_ephemeral` still owns the closed-set
196        // projection for consumers that hold the variant by borrow;
197        // this projection is the compound-lift entry point for
198        // consumers whose call graph starts from `&Lifetime`.
199        match self.variant().ok()? {
200            LifetimeVariant::Ephemeral(e) => Some(e),
201            LifetimeVariant::Permanent(_) => None,
202        }
203    }
204}
205
206const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
207
208/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
209/// SIGTERM terminates only on explicit operator action.
210#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
211#[serde(rename_all = "camelCase")]
212pub struct PermanentLifetime {}
213
214/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
215///
216/// Phase semantics:
217/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
218///   reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
219/// - On `Failed`  with `teardown_policy ∈ {OnFailed,   Always}`:
220///   same. Otherwise Process stays at Failed for forensic inspection.
221/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
222///   reconcile loop tick. TTL expiry while in any non-terminal phase
223///   forces SIGTERM regardless of `teardown_policy`.
224#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct EphemeralLifetime {
227    /// `humantime`-parseable duration from `phaseSince(Forking)` after
228    /// which the Process is force-SIGTERM'd.
229    #[serde(default = "default_ttl")]
230    pub ttl: String,
231
232    /// When the Process auto-terminates.
233    #[serde(default)]
234    pub teardown_policy: TeardownPolicy,
235
236    /// Cluster-wide concurrency budget across ephemeral Processes that
237    /// share the same `spec.identity.name_override` / chart_ref.
238    /// `0` = no cap. Enforced by the reconciler before transitioning out
239    /// of `Pending`.
240    #[serde(default = "default_max_concurrent")]
241    pub max_concurrent: u32,
242
243    /// Declared exports — what artifacts survive teardown and where
244    /// they flow. Empty (default) = nothing survives, matching the
245    /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
246    /// independently triggered during the reconciler's `Releasing`
247    /// phase against the terminal `ProcessPhase` reached.
248    ///
249    /// See [`crate::export`] for the full type. All exports flow
250    /// through the pleme-io Vector + NATS layer — there is no
251    /// per-spec ad-hoc sink.
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub exports: Vec<ExportSpec>,
254}
255
256impl EphemeralLifetime {
257    /// True iff any declared export's [`crate::export::ExportTrigger`]
258    /// fires for the given terminal-reached phase. The reconciler
259    /// uses this to decide whether to route `Attested`/`Failed`
260    /// through `Releasing` (the export window) or skip straight to
261    /// `Exiting`/`Zombie`.
262    ///
263    /// Returns `false` when the export list is empty or no trigger
264    /// matches — both cases collapse to the existing teardown path.
265    pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
266        self.exports.iter().any(|e| e.when.fires_on(phase))
267    }
268
269    /// Iterate over the exports whose trigger fires on `phase`.
270    /// The reconciler's `handle_releasing` consumes this to emit
271    /// one tatara-export-worker Job per surviving spec.
272    pub fn applicable_exports(
273        &self,
274        phase: ProcessPhase,
275    ) -> impl Iterator<Item = &ExportSpec> + '_ {
276        self.exports.iter().filter(move |e| e.when.fires_on(phase))
277    }
278}
279
280impl Default for EphemeralLifetime {
281    fn default() -> Self {
282        Self {
283            ttl: default_ttl(),
284            teardown_policy: TeardownPolicy::default(),
285            max_concurrent: default_max_concurrent(),
286            exports: Vec::new(),
287        }
288    }
289}
290
291fn default_ttl() -> String {
292    "1h".to_string()
293}
294fn default_max_concurrent() -> u32 {
295    1
296}
297
298/// When an ephemeral Process self-terminates.
299///
300/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
301/// foreign success/failure language — typed phases are the source of truth.
302#[derive(
303    Clone,
304    Copy,
305    Debug,
306    PartialEq,
307    Eq,
308    Hash,
309    Serialize,
310    Deserialize,
311    JsonSchema,
312    Default,
313    tatara_closed_set::DeriveClosedSet,
314)]
315#[serde(rename_all = "PascalCase")]
316#[closed_set(via = "as_str", display, generate_unknown)]
317pub enum TeardownPolicy {
318    /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
319    #[default]
320    Always,
321    /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
322    OnAttested,
323    /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
324    /// TTL or explicit operator SIGTERM.
325    OnFailed,
326    /// Never auto-terminate (TTL still applies).
327    Never,
328}
329
330impl TeardownPolicy {
331    /// The closed set of teardown policies — single source of truth that
332    /// drives the `as_str` / Display / `FromStr` triad and the typed
333    /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
334    /// variant lands at one `ALL` entry + one `as_str` arm + one
335    /// `should_teardown_on` arm — exhaustively checked by the compiler
336    /// (the `[Self; 4]` array literal forces the arity).
337    ///
338    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
339    /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
340    /// [`crate::boundary::ConditionKind::ALL`],
341    /// [`crate::phase::ProcessPhase::ALL`],
342    /// [`crate::signal::ProcessSignal::ALL`].
343    pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
344
345    /// Canonical PascalCase wire-format projection — matches the serde
346    /// `rename_all = "PascalCase"` output verbatim. Used by Display
347    /// (single source of truth), by `FromStr` to identify the variant
348    /// from its annotation / status-field representation, and by
349    /// operator-facing reason strings the reconciler stamps without
350    /// reaching for `{:?}` Debug formatting. Pinned by
351    /// `teardown_policy_as_str_matches_serde`.
352    pub const fn as_str(self) -> &'static str {
353        match self {
354            Self::Always => "Always",
355            Self::OnAttested => "OnAttested",
356            Self::OnFailed => "OnFailed",
357            Self::Never => "Never",
358        }
359    }
360
361    /// True iff, given a `ProcessPhase`, this policy says "tear down."
362    /// ONE typed dispatch over the typed phase enum that replaces the
363    /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
364    /// predicates `lifetime_clock::evaluate` previously branched on.
365    /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
366    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
367    /// always return `false` — teardown is a terminal-phase decision.
368    ///
369    /// The legacy [`Self::should_teardown_on_attested`] /
370    /// [`Self::should_teardown_on_failed`] predicates remain as thin
371    /// delegates so existing call sites keep their narrow signatures;
372    /// the truth table is pinned by
373    /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
374    pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
375        match phase {
376            ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
377            ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
378            ProcessPhase::Pending
379            | ProcessPhase::Forking
380            | ProcessPhase::Execing
381            | ProcessPhase::Running
382            | ProcessPhase::Reconverging
383            | ProcessPhase::Releasing
384            | ProcessPhase::Exiting
385            | ProcessPhase::Zombie
386            | ProcessPhase::Reaped => false,
387        }
388    }
389
390    /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
391    /// case — kept so existing call sites (notably the truth-table
392    /// test in this module) keep their narrow signature without
393    /// reaching for the typed-phase variant.
394    pub const fn should_teardown_on_attested(self) -> bool {
395        self.should_teardown_on(ProcessPhase::Attested)
396    }
397
398    /// Symmetric delegate to [`Self::should_teardown_on`] for the
399    /// `Failed` case.
400    pub const fn should_teardown_on_failed(self) -> bool {
401        self.should_teardown_on(ProcessPhase::Failed)
402    }
403}
404
405// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
406// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
407// `pub struct UnknownTeardownPolicy(pub String)` are generated by
408// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
409// "as_str", display, generate_unknown)]` on the enum declaration above.
410// The auto-derived label `"teardown policy"` matches the prior hand-
411// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
412// inherent `as_str` projection stays load-bearing — the PascalCase
413// wire-format that matches the serde rename + the reconciler's reason-
414// string emission verbatim — while the trait method `label` gives
415// generic consumers a STABLE name across the 36+ workspace-wide
416// closed-set implementors.
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn default_lifetime_resolves_to_permanent() {
424        let l = Lifetime::default();
425        assert!(l.is_default());
426        assert!(!l.is_ephemeral());
427        assert!(matches!(
428            l.variant().unwrap(),
429            LifetimeVariant::Permanent(_)
430        ));
431    }
432
433    #[test]
434    fn ephemeral_set_resolves() {
435        let l = Lifetime {
436            ephemeral: Some(EphemeralLifetime::default()),
437            ..Lifetime::default()
438        };
439        assert!(l.is_ephemeral());
440        match l.variant().unwrap() {
441            LifetimeVariant::Ephemeral(e) => {
442                assert_eq!(e.ttl, "1h");
443                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
444                assert_eq!(e.max_concurrent, 1);
445            }
446            other => panic!("expected ephemeral, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn ambiguous_lifetime_errors() {
452        let l = Lifetime {
453            permanent: Some(PermanentLifetime {}),
454            ephemeral: Some(EphemeralLifetime::default()),
455        };
456        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
457    }
458
459    #[test]
460    fn teardown_policy_dispatch() {
461        assert!(TeardownPolicy::Always.should_teardown_on_attested());
462        assert!(TeardownPolicy::Always.should_teardown_on_failed());
463        assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
464        assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
465        assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
466        assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
467        assert!(!TeardownPolicy::Never.should_teardown_on_attested());
468        assert!(!TeardownPolicy::Never.should_teardown_on_failed());
469    }
470
471    // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
472    //    should_teardown_on(phase)) ─
473
474    /// Structural well-formedness of [`TeardownPolicy`] as a
475    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
476    /// testkit lift that pins all three structural invariants (`ALL`
477    /// is non-empty, every variant round-trips through `label ↔
478    /// parse_label`, labels are pairwise distinct, `""` is outside the
479    /// closed set) at ONE call site. Replaces the hand-derived
480    /// `teardown_policy_all_is_unique_and_complete` +
481    /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
482    /// `unknown_teardown_policy_errors`. `FromStr` delegates to
483    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
484    /// exercises the same code path the reconciler hits when parsing a
485    /// CRD `enum:`-validated value back to the typed policy.
486    #[test]
487    fn teardown_policy_is_well_formed_closed_set() {
488        tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
489    }
490
491    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
492    /// output verbatim for every variant. A future variant rename
493    /// (or an `as_str` arm typo) lands here at one site. The reason
494    /// string `lifetime_clock::evaluate` stamps reaches for the same
495    /// projection via `Display`, so a Debug-vs-canonical drift would
496    /// surface here, not in operator-facing reason strings.
497    #[test]
498    fn teardown_policy_as_str_matches_serde() {
499        crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
500    }
501
502    /// The Display impl IS `as_str` — pinning this lets future
503    /// callers (notably `lifetime_clock::evaluate`'s reason string)
504    /// reach for either projection without drift.
505    #[test]
506    fn teardown_policy_display_matches_as_str() {
507        crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
508    }
509
510    /// `FromStr` rejects strings that aren't in the canonical
511    /// projection — lowercased / typo / unrelated — and the error
512    /// echoes the input verbatim so the operator-facing diagnostic
513    /// carries the offending value, not a normalized form. The
514    /// empty-input arm is pinned by
515    /// [`teardown_policy_is_well_formed_closed_set`] via the
516    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
517    /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
518    /// newtype, which the trait's `make_unknown` can't see.
519    #[test]
520    fn unknown_teardown_policy_errors() {
521        use std::str::FromStr;
522        for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
523            let err = TeardownPolicy::from_str(bad).unwrap_err();
524            assert_eq!(err.0, bad, "error payload should echo input verbatim");
525        }
526    }
527
528    /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
529    /// the documented (policy, phase) → bool table for every variant
530    /// at every typed phase. The two terminal phases (Attested,
531    /// Failed) carry the policy-specific result; every non-terminal
532    /// phase returns `false`. The closed-set sweep over both
533    /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
534    /// variant in either enum reaches this test by iteration — no
535    /// per-test array maintenance.
536    #[test]
537    fn teardown_policy_should_teardown_on_truth_table() {
538        for policy in TeardownPolicy::ALL {
539            for phase in ProcessPhase::ALL {
540                let expected = match phase {
541                    ProcessPhase::Attested => {
542                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
543                    }
544                    ProcessPhase::Failed => {
545                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
546                    }
547                    _ => false,
548                };
549                assert_eq!(
550                    policy.should_teardown_on(phase),
551                    expected,
552                    "should_teardown_on({policy:?}, {phase:?}) drift",
553                );
554            }
555        }
556    }
557
558    /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
559    /// `should_teardown_on_failed` predicates agree with the typed
560    /// `should_teardown_on(phase)` dispatch they delegate to, for
561    /// every variant. A regression that re-introduces an inline
562    /// `matches!` in either legacy predicate fails here the moment
563    /// `should_teardown_on` is the source of truth.
564    #[test]
565    fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
566        for policy in TeardownPolicy::ALL {
567            assert_eq!(
568                policy.should_teardown_on_attested(),
569                policy.should_teardown_on(ProcessPhase::Attested),
570                "Attested delegate drift for {policy:?}",
571            );
572            assert_eq!(
573                policy.should_teardown_on_failed(),
574                policy.should_teardown_on(ProcessPhase::Failed),
575                "Failed delegate drift for {policy:?}",
576            );
577        }
578    }
579
580    #[test]
581    fn serde_round_trip_ephemeral() {
582        let l = Lifetime {
583            ephemeral: Some(EphemeralLifetime {
584                ttl: "30m".into(),
585                teardown_policy: TeardownPolicy::OnAttested,
586                max_concurrent: 4,
587                exports: vec![],
588            }),
589            ..Lifetime::default()
590        };
591        let yaml = serde_yaml::to_string(&l).unwrap();
592        assert!(yaml.contains("ttl: 30m"));
593        assert!(yaml.contains("teardownPolicy: OnAttested"));
594        // Empty exports skip-serialize — explicit zero-trace default.
595        assert!(!yaml.contains("exports"));
596        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
597        assert!(back.is_ephemeral());
598        assert!(back.ephemeral.unwrap().exports.is_empty());
599    }
600
601    #[test]
602    fn applicable_exports_filters_by_trigger() {
603        use crate::export::{
604            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
605            VectorChannel,
606        };
607        let spec_attested = ExportSpec {
608            source: ArtifactSource {
609                receipts: Some(ReceiptsSource::default()),
610                ..ArtifactSource::default()
611            },
612            channel: VectorChannel {
613                http_event: Some(HttpEventChannel {
614                    endpoint: None,
615                    signal_type: "receipt".into(),
616                }),
617                ..VectorChannel::default()
618            },
619            when: ExportTrigger::OnAttested,
620            experiment_id_override: None,
621        };
622        let spec_failed = ExportSpec {
623            when: ExportTrigger::OnFailed,
624            ..spec_attested.clone()
625        };
626        let spec_always = ExportSpec {
627            when: ExportTrigger::Always,
628            ..spec_attested.clone()
629        };
630
631        let lt = EphemeralLifetime {
632            ttl: "1h".into(),
633            teardown_policy: TeardownPolicy::OnAttested,
634            max_concurrent: 1,
635            exports: vec![spec_attested, spec_failed, spec_always],
636        };
637
638        // Attested gate fires OnAttested + Always — 2 of 3.
639        assert!(lt.has_applicable_exports(ProcessPhase::Attested));
640        assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
641
642        // Failed gate fires OnFailed + Always — 2 of 3.
643        assert!(lt.has_applicable_exports(ProcessPhase::Failed));
644        assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
645
646        // Other phases never route through Releasing.
647        for p in [
648            ProcessPhase::Pending,
649            ProcessPhase::Forking,
650            ProcessPhase::Execing,
651            ProcessPhase::Running,
652            ProcessPhase::Reconverging,
653            ProcessPhase::Releasing,
654            ProcessPhase::Exiting,
655            ProcessPhase::Zombie,
656            ProcessPhase::Reaped,
657        ] {
658            assert!(!lt.has_applicable_exports(p));
659            assert_eq!(lt.applicable_exports(p).count(), 0);
660        }
661    }
662
663    #[test]
664    fn no_exports_means_no_applicable_exports() {
665        let lt = EphemeralLifetime::default();
666        assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
667        assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
668    }
669
670    /// `ALL` is the source of truth for the resolver sweep — pin its
671    /// closure so a variant added without an `ALL` entry fails here
672    /// (via the uniqueness check) before drifting `variant()`.
673    #[test]
674    fn lifetime_kind_all_is_unique_and_complete() {
675        let mut seen = std::collections::HashSet::new();
676        for kind in LifetimeKind::ALL {
677            assert!(seen.insert(kind), "duplicate variant in ALL: {kind:?}");
678        }
679        assert_eq!(seen.len(), LifetimeKind::ALL.len());
680    }
681
682    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
683    /// camelCase serde field name on `Lifetime`. A future rename of
684    /// any field lands here at one site.
685    #[test]
686    fn lifetime_kind_as_str_matches_lifetime_field_name() {
687        for kind in LifetimeKind::ALL {
688            let l = match kind {
689                LifetimeKind::Permanent => Lifetime {
690                    permanent: Some(PermanentLifetime {}),
691                    ..Lifetime::default()
692                },
693                LifetimeKind::Ephemeral => Lifetime {
694                    ephemeral: Some(EphemeralLifetime::default()),
695                    ..Lifetime::default()
696                },
697            };
698            let v = serde_json::to_value(&l).expect("Lifetime serializes");
699            let obj = v.as_object().expect("Lifetime serializes to object");
700            let keys: Vec<&String> = obj.keys().collect();
701            assert_eq!(
702                keys.len(),
703                1,
704                "exactly one slot populated for kind {kind:?}, got {keys:?}"
705            );
706            assert_eq!(
707                keys[0],
708                kind.as_str(),
709                "as_str() must match serde field name for {kind:?}"
710            );
711        }
712    }
713
714    /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
715    /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
716    /// projection composes the closed set in both directions — a
717    /// regression that misroutes a select arm (e.g. `Self::Permanent =>
718    /// l.ephemeral.as_ref()...`) fails loudly here.
719    #[test]
720    fn lifetime_kind_round_trips_through_variant_kind() {
721        for kind in LifetimeKind::ALL {
722            let l = single_slot_lifetime(kind);
723            let v = kind.select(&l).expect("populated slot must select");
724            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
725            // And the resolver lands on the same variant.
726            assert_eq!(
727                l.variant().expect("exactly-one variant").kind(),
728                kind,
729                "variant() resolver disagreed on {kind:?}"
730            );
731        }
732    }
733
734    /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
735    /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
736    /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
737    /// previously hand-rolled.
738    #[test]
739    fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
740        let permanent = PermanentLifetime {};
741        let v = LifetimeVariant::Permanent(&permanent);
742        assert!(v.as_ephemeral().is_none());
743        assert!(v.as_permanent().is_some());
744
745        let ephemeral = EphemeralLifetime {
746            ttl: "42m".into(),
747            teardown_policy: TeardownPolicy::OnAttested,
748            max_concurrent: 3,
749            exports: vec![],
750        };
751        let v = LifetimeVariant::Ephemeral(&ephemeral);
752        let inner = v.as_ephemeral().expect("ephemeral must project");
753        assert_eq!(inner.ttl, "42m");
754        assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
755        assert_eq!(inner.max_concurrent, 3);
756        assert!(v.as_permanent().is_none());
757    }
758
759    /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
760    /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
761    /// iff the resolver picks the ephemeral slot unambiguously. All
762    /// three failure modes (empty → permanent default, permanent-only,
763    /// ambiguous) collapse to `None`, matching the pre-lift
764    /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
765    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
766    ///
767    /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
768    /// permanent-only: an operator-authored spec with both slots
769    /// populated is a mis-configuration, and firing TTL / teardown on
770    /// it would be worse than skipping. Pinning that collapse here
771    /// closes the possibility of a future per-consumer drift where one
772    /// branch honors ambiguity (fires the timed action) and another
773    /// doesn't.
774    ///
775    /// The `Some` arm asserts byte-identity of the projected borrow
776    /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
777    /// silently swapped the projection to `self.permanent.as_ref()`
778    /// would surface here as a type mismatch rather than as a runtime
779    /// no-op in production.
780    #[test]
781    fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
782        // 1. Empty (both slots None) — resolves to Permanent default.
783        let l = Lifetime::default();
784        assert!(l.resolved_ephemeral().is_none());
785
786        // 2. Permanent-only.
787        let l = Lifetime {
788            permanent: Some(PermanentLifetime {}),
789            ..Lifetime::default()
790        };
791        assert!(l.resolved_ephemeral().is_none());
792
793        // 3. Ephemeral-only — the ONE arm that projects.
794        let ephemeral = EphemeralLifetime {
795            ttl: "13m".into(),
796            teardown_policy: TeardownPolicy::OnFailed,
797            max_concurrent: 7,
798            exports: vec![],
799        };
800        let l = Lifetime {
801            ephemeral: Some(ephemeral.clone()),
802            ..Lifetime::default()
803        };
804        let e = l.resolved_ephemeral().expect("ephemeral-only must project");
805        assert_eq!(e.ttl, "13m");
806        assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
807        assert_eq!(e.max_concurrent, 7);
808        // The borrow points into `self.ephemeral`, not into a temporary.
809        assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
810
811        // 4. Ambiguous (both slots set) — collapses to None, NOT to
812        //    the ephemeral inner. Guards against a future refactor
813        //    that silently unwrapped ambiguity to "prefer ephemeral".
814        let l = Lifetime {
815            permanent: Some(PermanentLifetime {}),
816            ephemeral: Some(EphemeralLifetime::default()),
817        };
818        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
819        assert!(l.resolved_ephemeral().is_none());
820    }
821
822    /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
823    /// set" outcome is `Permanent`, not an error. Pin via the
824    /// closed-set kind projection so a future variant added to the
825    /// closed set (and to the `Lifetime` struct) without updating
826    /// the default resolution would surface here — the default
827    /// stays `Permanent` regardless of the closed set's arity.
828    #[test]
829    fn empty_lifetime_resolves_to_permanent_kind() {
830        let l = Lifetime::default();
831        let v = l.variant().expect("default lifetime resolves");
832        assert_eq!(v.kind(), LifetimeKind::Permanent);
833        assert!(v.as_permanent().is_some());
834        assert!(v.as_ephemeral().is_none());
835    }
836
837    /// Construct a `Lifetime` with exactly the given kind's slot
838    /// populated by a minimal valid inner spec. Shared across the
839    /// closed-set property tests so they each cover every variant
840    /// without restating the construction table.
841    fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
842        match kind {
843            LifetimeKind::Permanent => Lifetime {
844                permanent: Some(PermanentLifetime {}),
845                ..Lifetime::default()
846            },
847            LifetimeKind::Ephemeral => Lifetime {
848                ephemeral: Some(EphemeralLifetime::default()),
849                ..Lifetime::default()
850            },
851        }
852    }
853
854    #[test]
855    fn exports_round_trip_through_lifetime() {
856        use crate::export::{
857            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
858            VectorChannel,
859        };
860        let l = Lifetime {
861            ephemeral: Some(EphemeralLifetime {
862                ttl: "30m".into(),
863                teardown_policy: TeardownPolicy::OnAttested,
864                max_concurrent: 1,
865                exports: vec![ExportSpec {
866                    source: ArtifactSource {
867                        receipts: Some(ReceiptsSource::default()),
868                        ..ArtifactSource::default()
869                    },
870                    channel: VectorChannel {
871                        http_event: Some(HttpEventChannel {
872                            endpoint: None,
873                            signal_type: "receipt".into(),
874                        }),
875                        ..VectorChannel::default()
876                    },
877                    when: ExportTrigger::OnAttested,
878                    experiment_id_override: None,
879                }],
880            }),
881            ..Lifetime::default()
882        };
883        let yaml = serde_yaml::to_string(&l).unwrap();
884        assert!(yaml.contains("exports:"));
885        assert!(yaml.contains("receipts: {}"));
886        assert!(yaml.contains("signalType: receipt"));
887        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
888        let e = back.ephemeral.unwrap();
889        assert_eq!(e.exports.len(), 1);
890        assert!(e.exports[0].source.receipts.is_some());
891        assert!(e.exports[0].channel.http_event.is_some());
892    }
893}