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