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    /// True iff any declared export's [`crate::export::ExportTrigger`]
271    /// fires for the given terminal-reached phase. The reconciler
272    /// uses this to decide whether to route `Attested`/`Failed`
273    /// through `Releasing` (the export window) or skip straight to
274    /// `Exiting`/`Zombie`.
275    ///
276    /// Returns `false` when the export list is empty or no trigger
277    /// matches — both cases collapse to the existing teardown path.
278    pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
279        self.exports.iter().any(|e| e.when.fires_on(phase))
280    }
281
282    /// Iterate over the exports whose trigger fires on `phase`.
283    /// The reconciler's `handle_releasing` consumes this to emit
284    /// one tatara-export-worker Job per surviving spec.
285    pub fn applicable_exports(
286        &self,
287        phase: ProcessPhase,
288    ) -> impl Iterator<Item = &ExportSpec> + '_ {
289        self.exports.iter().filter(move |e| e.when.fires_on(phase))
290    }
291}
292
293impl Default for EphemeralLifetime {
294    fn default() -> Self {
295        Self {
296            ttl: default_ttl(),
297            teardown_policy: TeardownPolicy::default(),
298            max_concurrent: default_max_concurrent(),
299            exports: Vec::new(),
300        }
301    }
302}
303
304fn default_ttl() -> String {
305    "1h".to_string()
306}
307fn default_max_concurrent() -> u32 {
308    1
309}
310
311/// When an ephemeral Process self-terminates.
312///
313/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
314/// foreign success/failure language — typed phases are the source of truth.
315#[derive(
316    Clone,
317    Copy,
318    Debug,
319    PartialEq,
320    Eq,
321    Hash,
322    Serialize,
323    Deserialize,
324    JsonSchema,
325    Default,
326    tatara_closed_set::DeriveClosedSet,
327)]
328#[serde(rename_all = "PascalCase")]
329#[closed_set(via = "as_str", display, generate_unknown)]
330pub enum TeardownPolicy {
331    /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
332    #[default]
333    Always,
334    /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
335    OnAttested,
336    /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
337    /// TTL or explicit operator SIGTERM.
338    OnFailed,
339    /// Never auto-terminate (TTL still applies).
340    Never,
341}
342
343impl TeardownPolicy {
344    /// The closed set of teardown policies — single source of truth that
345    /// drives the `as_str` / Display / `FromStr` triad and the typed
346    /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
347    /// variant lands at one `ALL` entry + one `as_str` arm + one
348    /// `should_teardown_on` arm — exhaustively checked by the compiler
349    /// (the `[Self; 4]` array literal forces the arity).
350    ///
351    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
352    /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
353    /// [`crate::boundary::ConditionKind::ALL`],
354    /// [`crate::phase::ProcessPhase::ALL`],
355    /// [`crate::signal::ProcessSignal::ALL`].
356    pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
357
358    /// Canonical PascalCase wire-format projection — matches the serde
359    /// `rename_all = "PascalCase"` output verbatim. Used by Display
360    /// (single source of truth), by `FromStr` to identify the variant
361    /// from its annotation / status-field representation, and by
362    /// operator-facing reason strings the reconciler stamps without
363    /// reaching for `{:?}` Debug formatting. Pinned by
364    /// `teardown_policy_as_str_matches_serde`.
365    pub const fn as_str(self) -> &'static str {
366        match self {
367            Self::Always => "Always",
368            Self::OnAttested => "OnAttested",
369            Self::OnFailed => "OnFailed",
370            Self::Never => "Never",
371        }
372    }
373
374    /// True iff, given a `ProcessPhase`, this policy says "tear down."
375    /// ONE typed dispatch over the typed phase enum that replaces the
376    /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
377    /// predicates `lifetime_clock::evaluate` previously branched on.
378    /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
379    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
380    /// always return `false` — teardown is a terminal-phase decision.
381    ///
382    /// The legacy [`Self::should_teardown_on_attested`] /
383    /// [`Self::should_teardown_on_failed`] predicates remain as thin
384    /// delegates so existing call sites keep their narrow signatures;
385    /// the truth table is pinned by
386    /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
387    pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
388        match phase {
389            ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
390            ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
391            ProcessPhase::Pending
392            | ProcessPhase::Forking
393            | ProcessPhase::Execing
394            | ProcessPhase::Running
395            | ProcessPhase::Reconverging
396            | ProcessPhase::Releasing
397            | ProcessPhase::Exiting
398            | ProcessPhase::Zombie
399            | ProcessPhase::Reaped => false,
400        }
401    }
402
403    /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
404    /// case — kept so existing call sites (notably the truth-table
405    /// test in this module) keep their narrow signature without
406    /// reaching for the typed-phase variant.
407    pub const fn should_teardown_on_attested(self) -> bool {
408        self.should_teardown_on(ProcessPhase::Attested)
409    }
410
411    /// Symmetric delegate to [`Self::should_teardown_on`] for the
412    /// `Failed` case.
413    pub const fn should_teardown_on_failed(self) -> bool {
414        self.should_teardown_on(ProcessPhase::Failed)
415    }
416}
417
418// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
419// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
420// `pub struct UnknownTeardownPolicy(pub String)` are generated by
421// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
422// "as_str", display, generate_unknown)]` on the enum declaration above.
423// The auto-derived label `"teardown policy"` matches the prior hand-
424// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
425// inherent `as_str` projection stays load-bearing — the PascalCase
426// wire-format that matches the serde rename + the reconciler's reason-
427// string emission verbatim — while the trait method `label` gives
428// generic consumers a STABLE name across the 36+ workspace-wide
429// closed-set implementors.
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn default_lifetime_resolves_to_permanent() {
437        let l = Lifetime::default();
438        assert!(l.is_default());
439        assert!(!l.is_ephemeral());
440        assert!(matches!(
441            l.variant().unwrap(),
442            LifetimeVariant::Permanent(_)
443        ));
444    }
445
446    #[test]
447    fn ephemeral_set_resolves() {
448        let l = Lifetime {
449            ephemeral: Some(EphemeralLifetime::default()),
450            ..Lifetime::default()
451        };
452        assert!(l.is_ephemeral());
453        match l.variant().unwrap() {
454            LifetimeVariant::Ephemeral(e) => {
455                assert_eq!(e.ttl, "1h");
456                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
457                assert_eq!(e.max_concurrent, 1);
458            }
459            other => panic!("expected ephemeral, got {other:?}"),
460        }
461    }
462
463    #[test]
464    fn ambiguous_lifetime_errors() {
465        let l = Lifetime {
466            permanent: Some(PermanentLifetime {}),
467            ephemeral: Some(EphemeralLifetime::default()),
468        };
469        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
470    }
471
472    #[test]
473    fn teardown_policy_dispatch() {
474        assert!(TeardownPolicy::Always.should_teardown_on_attested());
475        assert!(TeardownPolicy::Always.should_teardown_on_failed());
476        assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
477        assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
478        assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
479        assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
480        assert!(!TeardownPolicy::Never.should_teardown_on_attested());
481        assert!(!TeardownPolicy::Never.should_teardown_on_failed());
482    }
483
484    // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
485    //    should_teardown_on(phase)) ─
486
487    /// Structural well-formedness of [`TeardownPolicy`] as a
488    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
489    /// testkit lift that pins all three structural invariants (`ALL`
490    /// is non-empty, every variant round-trips through `label ↔
491    /// parse_label`, labels are pairwise distinct, `""` is outside the
492    /// closed set) at ONE call site. Replaces the hand-derived
493    /// `teardown_policy_all_is_unique_and_complete` +
494    /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
495    /// `unknown_teardown_policy_errors`. `FromStr` delegates to
496    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
497    /// exercises the same code path the reconciler hits when parsing a
498    /// CRD `enum:`-validated value back to the typed policy.
499    #[test]
500    fn teardown_policy_is_well_formed_closed_set() {
501        tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
502    }
503
504    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
505    /// output verbatim for every variant. A future variant rename
506    /// (or an `as_str` arm typo) lands here at one site. The reason
507    /// string `lifetime_clock::evaluate` stamps reaches for the same
508    /// projection via `Display`, so a Debug-vs-canonical drift would
509    /// surface here, not in operator-facing reason strings.
510    #[test]
511    fn teardown_policy_as_str_matches_serde() {
512        crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
513    }
514
515    /// The Display impl IS `as_str` — pinning this lets future
516    /// callers (notably `lifetime_clock::evaluate`'s reason string)
517    /// reach for either projection without drift.
518    #[test]
519    fn teardown_policy_display_matches_as_str() {
520        crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
521    }
522
523    /// `FromStr` rejects strings that aren't in the canonical
524    /// projection — lowercased / typo / unrelated — and the error
525    /// echoes the input verbatim so the operator-facing diagnostic
526    /// carries the offending value, not a normalized form. The
527    /// empty-input arm is pinned by
528    /// [`teardown_policy_is_well_formed_closed_set`] via the
529    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
530    /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
531    /// newtype, which the trait's `make_unknown` can't see.
532    #[test]
533    fn unknown_teardown_policy_errors() {
534        use std::str::FromStr;
535        for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
536            let err = TeardownPolicy::from_str(bad).unwrap_err();
537            assert_eq!(err.0, bad, "error payload should echo input verbatim");
538        }
539    }
540
541    /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
542    /// the documented (policy, phase) → bool table for every variant
543    /// at every typed phase. The two terminal phases (Attested,
544    /// Failed) carry the policy-specific result; every non-terminal
545    /// phase returns `false`. The closed-set sweep over both
546    /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
547    /// variant in either enum reaches this test by iteration — no
548    /// per-test array maintenance.
549    #[test]
550    fn teardown_policy_should_teardown_on_truth_table() {
551        for policy in TeardownPolicy::ALL {
552            for phase in ProcessPhase::ALL {
553                let expected = match phase {
554                    ProcessPhase::Attested => {
555                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
556                    }
557                    ProcessPhase::Failed => {
558                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
559                    }
560                    _ => false,
561                };
562                assert_eq!(
563                    policy.should_teardown_on(phase),
564                    expected,
565                    "should_teardown_on({policy:?}, {phase:?}) drift",
566                );
567            }
568        }
569    }
570
571    /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
572    /// `should_teardown_on_failed` predicates agree with the typed
573    /// `should_teardown_on(phase)` dispatch they delegate to, for
574    /// every variant. A regression that re-introduces an inline
575    /// `matches!` in either legacy predicate fails here the moment
576    /// `should_teardown_on` is the source of truth.
577    #[test]
578    fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
579        for policy in TeardownPolicy::ALL {
580            assert_eq!(
581                policy.should_teardown_on_attested(),
582                policy.should_teardown_on(ProcessPhase::Attested),
583                "Attested delegate drift for {policy:?}",
584            );
585            assert_eq!(
586                policy.should_teardown_on_failed(),
587                policy.should_teardown_on(ProcessPhase::Failed),
588                "Failed delegate drift for {policy:?}",
589            );
590        }
591    }
592
593    #[test]
594    fn serde_round_trip_ephemeral() {
595        let l = Lifetime {
596            ephemeral: Some(EphemeralLifetime {
597                ttl: "30m".into(),
598                teardown_policy: TeardownPolicy::OnAttested,
599                max_concurrent: 4,
600                exports: vec![],
601            }),
602            ..Lifetime::default()
603        };
604        let yaml = serde_yaml::to_string(&l).unwrap();
605        assert!(yaml.contains("ttl: 30m"));
606        assert!(yaml.contains("teardownPolicy: OnAttested"));
607        // Empty exports skip-serialize — explicit zero-trace default.
608        assert!(!yaml.contains("exports"));
609        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
610        assert!(back.is_ephemeral());
611        assert!(back.ephemeral.unwrap().exports.is_empty());
612    }
613
614    #[test]
615    fn applicable_exports_filters_by_trigger() {
616        use crate::export::{
617            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
618            VectorChannel,
619        };
620        let spec_attested = ExportSpec {
621            source: ArtifactSource {
622                receipts: Some(ReceiptsSource::default()),
623                ..ArtifactSource::default()
624            },
625            channel: VectorChannel {
626                http_event: Some(HttpEventChannel {
627                    endpoint: None,
628                    signal_type: "receipt".into(),
629                }),
630                ..VectorChannel::default()
631            },
632            when: ExportTrigger::OnAttested,
633            experiment_id_override: None,
634        };
635        let spec_failed = ExportSpec {
636            when: ExportTrigger::OnFailed,
637            ..spec_attested.clone()
638        };
639        let spec_always = ExportSpec {
640            when: ExportTrigger::Always,
641            ..spec_attested.clone()
642        };
643
644        let lt = EphemeralLifetime {
645            ttl: "1h".into(),
646            teardown_policy: TeardownPolicy::OnAttested,
647            max_concurrent: 1,
648            exports: vec![spec_attested, spec_failed, spec_always],
649        };
650
651        // Attested gate fires OnAttested + Always — 2 of 3.
652        assert!(lt.has_applicable_exports(ProcessPhase::Attested));
653        assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
654
655        // Failed gate fires OnFailed + Always — 2 of 3.
656        assert!(lt.has_applicable_exports(ProcessPhase::Failed));
657        assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
658
659        // Other phases never route through Releasing.
660        for p in [
661            ProcessPhase::Pending,
662            ProcessPhase::Forking,
663            ProcessPhase::Execing,
664            ProcessPhase::Running,
665            ProcessPhase::Reconverging,
666            ProcessPhase::Releasing,
667            ProcessPhase::Exiting,
668            ProcessPhase::Zombie,
669            ProcessPhase::Reaped,
670        ] {
671            assert!(!lt.has_applicable_exports(p));
672            assert_eq!(lt.applicable_exports(p).count(), 0);
673        }
674    }
675
676    #[test]
677    fn no_exports_means_no_applicable_exports() {
678        let lt = EphemeralLifetime::default();
679        assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
680        assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
681    }
682
683    /// Structural well-formedness of [`LifetimeKind`] as a
684    /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-
685    /// wide testkit that pins ALL structural invariants (`ALL` is
686    /// non-empty, every variant round-trips through `label ↔
687    /// parse_label`, labels are pairwise distinct, `""` is outside
688    /// the closed set, the [`UnknownLifetimeKind`] carrier's Display
689    /// renders the substrate-wide `"unknown lifetime kind: <input>"`
690    /// shape, `labels()` equals the natural `ALL × label` projection)
691    /// at ONE call site. Subsumes the hand-derived
692    /// `lifetime_kind_all_is_unique_and_complete` sweep the pre-derive
693    /// site published — clauses (1)+(3) of the testkit fold uniqueness
694    /// + non-emptiness into the substrate primitive's own body.
695    #[test]
696    fn lifetime_kind_is_well_formed_closed_set() {
697        tatara_closed_set::assert_closed_set_well_formed::<LifetimeKind>();
698    }
699
700    /// The Display impl IS `as_str` — pinning this lets future callers
701    /// reach for either projection without drift. Symmetric to every
702    /// sibling `X_display_matches_as_str` invariant across
703    /// `tatara-process`; routes through the substrate primitive
704    /// [`crate::tagged_union::assert_display_matches_label`] shared
705    /// with all 29+ production Display-alignment sites. The auto-
706    /// derived `Display` body from `#[closed_set(via = "as_str",
707    /// display)]` emits the substrate-wide `f.write_str(Self::as_str
708    /// (*self))` shape — a regression that regresses `as_str` (or a
709    /// future hand-rolled Display block that drifts from `as_str`)
710    /// surfaces here at the substrate-wide alignment probe.
711    #[test]
712    fn lifetime_kind_display_matches_as_str() {
713        crate::tagged_union::assert_display_matches_label::<LifetimeKind>();
714    }
715
716    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
717    /// camelCase serde field name on `Lifetime`. A future rename of
718    /// any field lands here at one site — and the wire-key alignment
719    /// stays coherent with the operator-facing serde shape.
720    ///
721    /// Routes through the substrate primitive
722    /// [`crate::tagged_union::assert_wire_key_matches_label`] — the
723    /// bound-relaxed peer of `assert_single_slot_key_matches_label`
724    /// that drops the `T: TaggedUnion` requirement so `Lifetime`
725    /// (whose empty variant resolves to `Permanent(&DEFAULT_PERMANENT)`
726    /// rather than to a [`crate::tagged_union::TaggedUnionError::empty`]
727    /// carrier) still binds through ONE substrate wire-key alignment
728    /// site. Pre-lift the body restated the same serialize +
729    /// exactly-one-key + name-equality sweep at this test surface
730    /// verbatim; post-lift the projection lives at ONE substrate
731    /// primitive and this site binds through a single call — the
732    /// same mechanical shape the four sibling TaggedUnion parents
733    /// carry via the trait-projected [`crate::tagged_union::assert_single_slot_key_matches_label`].
734    #[test]
735    fn lifetime_kind_as_str_matches_lifetime_field_name() {
736        crate::tagged_union::assert_wire_key_matches_label::<Lifetime, LifetimeKind, _>(
737            single_slot_lifetime,
738        );
739    }
740
741    /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
742    /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
743    /// projection composes the closed set in both directions — a
744    /// regression that misroutes a select arm (e.g. `Self::Permanent =>
745    /// l.ephemeral.as_ref()...`) fails loudly here.
746    #[test]
747    fn lifetime_kind_round_trips_through_variant_kind() {
748        for kind in LifetimeKind::ALL {
749            let l = single_slot_lifetime(kind);
750            let v = kind.select(&l).expect("populated slot must select");
751            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
752            // And the resolver lands on the same variant.
753            assert_eq!(
754                l.variant().expect("exactly-one variant").kind(),
755                kind,
756                "variant() resolver disagreed on {kind:?}"
757            );
758        }
759    }
760
761    /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
762    /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
763    /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
764    /// previously hand-rolled.
765    #[test]
766    fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
767        let permanent = PermanentLifetime {};
768        let v = LifetimeVariant::Permanent(&permanent);
769        assert!(v.as_ephemeral().is_none());
770        assert!(v.as_permanent().is_some());
771
772        let ephemeral = EphemeralLifetime {
773            ttl: "42m".into(),
774            teardown_policy: TeardownPolicy::OnAttested,
775            max_concurrent: 3,
776            exports: vec![],
777        };
778        let v = LifetimeVariant::Ephemeral(&ephemeral);
779        let inner = v.as_ephemeral().expect("ephemeral must project");
780        assert_eq!(inner.ttl, "42m");
781        assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
782        assert_eq!(inner.max_concurrent, 3);
783        assert!(v.as_permanent().is_none());
784    }
785
786    /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
787    /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
788    /// iff the resolver picks the ephemeral slot unambiguously. All
789    /// three failure modes (empty → permanent default, permanent-only,
790    /// ambiguous) collapse to `None`, matching the pre-lift
791    /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
792    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
793    ///
794    /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
795    /// permanent-only: an operator-authored spec with both slots
796    /// populated is a mis-configuration, and firing TTL / teardown on
797    /// it would be worse than skipping. Pinning that collapse here
798    /// closes the possibility of a future per-consumer drift where one
799    /// branch honors ambiguity (fires the timed action) and another
800    /// doesn't.
801    ///
802    /// The `Some` arm asserts byte-identity of the projected borrow
803    /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
804    /// silently swapped the projection to `self.permanent.as_ref()`
805    /// would surface here as a type mismatch rather than as a runtime
806    /// no-op in production.
807    #[test]
808    fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
809        // 1. Empty (both slots None) — resolves to Permanent default.
810        let l = Lifetime::default();
811        assert!(l.resolved_ephemeral().is_none());
812
813        // 2. Permanent-only.
814        let l = Lifetime {
815            permanent: Some(PermanentLifetime {}),
816            ..Lifetime::default()
817        };
818        assert!(l.resolved_ephemeral().is_none());
819
820        // 3. Ephemeral-only — the ONE arm that projects.
821        let ephemeral = EphemeralLifetime {
822            ttl: "13m".into(),
823            teardown_policy: TeardownPolicy::OnFailed,
824            max_concurrent: 7,
825            exports: vec![],
826        };
827        let l = Lifetime {
828            ephemeral: Some(ephemeral.clone()),
829            ..Lifetime::default()
830        };
831        let e = l.resolved_ephemeral().expect("ephemeral-only must project");
832        assert_eq!(e.ttl, "13m");
833        assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
834        assert_eq!(e.max_concurrent, 7);
835        // The borrow points into `self.ephemeral`, not into a temporary.
836        assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
837
838        // 4. Ambiguous (both slots set) — collapses to None, NOT to
839        //    the ephemeral inner. Guards against a future refactor
840        //    that silently unwrapped ambiguity to "prefer ephemeral".
841        let l = Lifetime {
842            permanent: Some(PermanentLifetime {}),
843            ephemeral: Some(EphemeralLifetime::default()),
844        };
845        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
846        assert!(l.resolved_ephemeral().is_none());
847    }
848
849    /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
850    /// set" outcome is `Permanent`, not an error. Pin via the
851    /// closed-set kind projection so a future variant added to the
852    /// closed set (and to the `Lifetime` struct) without updating
853    /// the default resolution would surface here — the default
854    /// stays `Permanent` regardless of the closed set's arity.
855    #[test]
856    fn empty_lifetime_resolves_to_permanent_kind() {
857        let l = Lifetime::default();
858        let v = l.variant().expect("default lifetime resolves");
859        assert_eq!(v.kind(), LifetimeKind::Permanent);
860        assert!(v.as_permanent().is_some());
861        assert!(v.as_ephemeral().is_none());
862    }
863
864    /// Construct a `Lifetime` with exactly the given kind's slot
865    /// populated by a minimal valid inner spec. Shared across the
866    /// closed-set property tests so they each cover every variant
867    /// without restating the construction table.
868    fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
869        match kind {
870            LifetimeKind::Permanent => Lifetime {
871                permanent: Some(PermanentLifetime {}),
872                ..Lifetime::default()
873            },
874            LifetimeKind::Ephemeral => Lifetime {
875                ephemeral: Some(EphemeralLifetime::default()),
876                ..Lifetime::default()
877            },
878        }
879    }
880
881    #[test]
882    fn exports_round_trip_through_lifetime() {
883        use crate::export::{
884            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
885            VectorChannel,
886        };
887        let l = Lifetime {
888            ephemeral: Some(EphemeralLifetime {
889                ttl: "30m".into(),
890                teardown_policy: TeardownPolicy::OnAttested,
891                max_concurrent: 1,
892                exports: vec![ExportSpec {
893                    source: ArtifactSource {
894                        receipts: Some(ReceiptsSource::default()),
895                        ..ArtifactSource::default()
896                    },
897                    channel: VectorChannel {
898                        http_event: Some(HttpEventChannel {
899                            endpoint: None,
900                            signal_type: "receipt".into(),
901                        }),
902                        ..VectorChannel::default()
903                    },
904                    when: ExportTrigger::OnAttested,
905                    experiment_id_override: None,
906                }],
907            }),
908            ..Lifetime::default()
909        };
910        let yaml = serde_yaml::to_string(&l).unwrap();
911        assert!(yaml.contains("exports:"));
912        assert!(yaml.contains("receipts: {}"));
913        assert!(yaml.contains("signalType: receipt"));
914        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
915        let e = back.ephemeral.unwrap();
916        assert_eq!(e.exports.len(), 1);
917        assert!(e.exports[0].source.receipts.is_some());
918        assert!(e.exports[0].channel.http_event.is_some());
919    }
920}