Skip to main content

tatara_process/
lifetime_clock.rs

1//! Ephemeral lifetime clock — TTL expiry + teardown-policy decisions.
2//!
3//! The reconciler consults this module at each phase tick to decide
4//! whether a Process should auto-terminate:
5//! - TTL is measured from `metadata.creation_timestamp` (the most
6//!   deterministic anchor — phaseSince resets per phase).
7//! - Teardown policy applies on `Attested` or `Failed` per
8//!   `EphemeralLifetime.teardown_policy`.
9//!
10//! Returning `AutoTerminate::Now { reason }` tells the caller to transition
11//! the Process to `Exiting`. The phase machine handles the SIGTERM path
12//! from there (children drained, finalizer guards owned resources).
13
14use chrono::{DateTime, Utc};
15use std::fmt;
16use std::time::Duration;
17
18use crate::crd::Process;
19use crate::lifetime::TeardownPolicy;
20use crate::phase::ProcessPhase;
21
22/// Decision the phase machine acts on.
23///
24/// Two-variant payload-carrying enum: `Skip` carries no payload (no-op
25/// signal to the controller), `Now` carries the typed [`TerminateReason`]
26/// that the controller stamps onto `status.message`. The
27/// (payload-carrying-enum, payload-stripped-typed-discriminator) split
28/// — `Now(reason)` on the wire-shape, [`AutoTerminateKind::Now`] for
29/// closed dispatch — is the same shape every sibling closed-set lift
30/// in this crate carries (see [`crate::lifetime_clock::TerminateReason`]
31/// → [`TerminateReasonKind`], [`crate::matrix::SelectStrategy`] →
32/// [`crate::matrix::SelectStrategyKind`]).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum AutoTerminate {
35    /// No auto-terminate signal — continue with the normal phase handler.
36    Skip,
37    /// Transition the Process to `Exiting` with the given operator-visible reason.
38    Now { reason: TerminateReason },
39}
40
41impl AutoTerminate {
42    /// Discriminator projection — strips the [`Now`]-variant payload and
43    /// returns the closed-set kind. Used by the kind-sweep tests and by
44    /// any future consumer that groups decisions by category (metrics
45    /// labels, dashboard enumeration, `status.conditions[].reason`
46    /// reason-keys) without pattern-matching the full payload.
47    ///
48    /// [`Now`]: AutoTerminate::Now
49    pub const fn kind(&self) -> AutoTerminateKind {
50        match self {
51            Self::Skip => AutoTerminateKind::Skip,
52            Self::Now { .. } => AutoTerminateKind::Now,
53        }
54    }
55
56    /// Reason projection — `Some(&reason)` when the decision is
57    /// [`Now`], `None` when [`Skip`]. The closed-set predicate dual:
58    /// callers that need only the payload (e.g. to stamp
59    /// `status.message`) reach through this projection instead of the
60    /// inline `if let AutoTerminate::Now { reason } = …` destructure,
61    /// so the variant-name → payload-field binding lives at ONE site.
62    /// Adding a third payload-carrying variant in the future updates
63    /// every consumer through this method's exhaustiveness check
64    /// rather than scattering destructures across the call graph.
65    ///
66    /// [`Now`]: AutoTerminate::Now
67    /// [`Skip`]: AutoTerminate::Skip
68    pub const fn reason(&self) -> Option<&TerminateReason> {
69        match self {
70            Self::Skip => None,
71            Self::Now { reason } => Some(reason),
72        }
73    }
74
75    /// `true` iff the decision is [`Now`]. Symmetric to [`Self::is_skip`].
76    ///
77    /// [`Now`]: AutoTerminate::Now
78    pub const fn is_now(&self) -> bool {
79        matches!(self, Self::Now { .. })
80    }
81
82    /// `true` iff the decision is [`Skip`]. Symmetric to [`Self::is_now`].
83    ///
84    /// [`Skip`]: AutoTerminate::Skip
85    pub const fn is_skip(&self) -> bool {
86        matches!(self, Self::Skip)
87    }
88}
89
90/// The closed set of [`AutoTerminate`] kinds — the discriminator view,
91/// payload-stripped, that sibling closed-set enums in this crate carry
92/// (see [`TerminateReasonKind`], [`crate::matrix::SelectStrategyKind`],
93/// [`crate::lifetime::LifetimeKind`]).
94///
95/// Drives the `as_str` / Display / `FromStr` triad over [`Self::ALL`] so
96/// a new variant added with an `ALL` entry automatically extends the
97/// parser, the canonical wire-format projection, and any future
98/// metrics-label / dashboard / `status.conditions[].reason` enumeration
99/// that needs to enumerate the decision categories. The `[Self; 2]`
100/// array literal forces the arity so a third variant cannot land
101/// without bumping the constant.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
103#[closed_set(via = "as_str", display, generate_unknown = "auto-terminate kind")]
104pub enum AutoTerminateKind {
105    /// The kind-view of [`AutoTerminate::Skip`].
106    Skip,
107    /// The kind-view of [`AutoTerminate::Now`] — the payload is
108    /// stripped at this projection.
109    Now,
110}
111
112impl AutoTerminateKind {
113    /// The closed set — single source of truth for `as_str` / Display /
114    /// `FromStr`.
115    pub const ALL: [Self; 2] = [Self::Skip, Self::Now];
116
117    /// Canonical PascalCase wire-format projection. Mirrors the
118    /// `tatara-process` PascalCase idiom used by every other closed-set
119    /// enum's `as_str` projection (e.g. [`ProcessPhase::as_str`],
120    /// [`TerminateReasonKind::as_str`]). A future metrics-label /
121    /// `status.conditions[].reason` field reads this projection
122    /// directly.
123    pub const fn as_str(self) -> &'static str {
124        match self {
125            Self::Skip => "Skip",
126            Self::Now => "Now",
127        }
128    }
129}
130
131// `impl fmt::Display for AutoTerminateKind` + `impl FromStr for
132// AutoTerminateKind` + `impl tatara_lisp::ClosedSet for
133// AutoTerminateKind` + `pub struct UnknownAutoTerminateKind(pub
134// String)` are generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
135// `#[closed_set(via = "as_str", display, generate_unknown =
136// "auto-terminate kind")]` on the enum declaration above. The explicit
137// label pins the pre-lift wording (with hyphen) against the auto-
138// projection `pascal_to_spaced_lowercase("AutoTerminateKind")` →
139// "auto terminate kind" (no hyphen) — the operator-facing
140// `#[error("unknown auto-terminate kind: {0}")]` annotation stays byte-
141// for-byte identical to the pre-lift hand-roll. The inherent `as_str`
142// projection stays load-bearing — the PascalCase wire-format the
143// `evaluate` decision-projection's emitted reason reads — while the
144// trait method `label` gives generic consumers a STABLE name across
145// the workspace-wide closed-set implementors.
146
147/// Why the ephemeral lifetime clock fired.
148///
149/// Typed image of the two reason strings the pre-lift evaluator composed
150/// inline with `format!(…)`. Each variant carries the typed payload its
151/// `Display` formats against the canonical PascalCase projection of
152/// [`TeardownPolicy`] / [`ProcessPhase`], so the operator-visible reason
153/// is read off the typed surface rather than a free-form template that
154/// could drift on a variant rename. The reason string is the deliverable
155/// the reconciler stamps onto `status.message`; this enum is the source
156/// of truth.
157///
158/// Adding a third cause (e.g. parent-cascade from a SIGKILL'd parent in
159/// the hierarchical PID model, OOM-style memory-pressure pre-emption, or
160/// a future ResourceQuota gate) lands at one variant + one [`Display`]
161/// arm + one [`TerminateReasonKind`] entry — exhaustively checked by the
162/// compiler AND by the per-variant truth-table tests.
163///
164/// Sibling closed-set lifts on the same `tatara-process` axis:
165/// [`crate::intent::IntentKind::ALL`], [`crate::LifetimeKind::ALL`],
166/// [`crate::lifetime::TeardownPolicy::ALL`],
167/// [`crate::boundary::ConditionKind::ALL`],
168/// [`crate::phase::ProcessPhase::ALL`],
169/// [`crate::signal::ProcessSignal::ALL`].
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum TerminateReason {
172    /// The Process reached a terminal-gate phase ([`ProcessPhase::Attested`]
173    /// or [`ProcessPhase::Failed`]) and the ephemeral lifetime's
174    /// [`TeardownPolicy`] elected to fire on that phase.
175    TeardownPolicy {
176        policy: TeardownPolicy,
177        phase: ProcessPhase,
178    },
179    /// The ephemeral lifetime's TTL elapsed in a non-terminal phase.
180    /// `ttl` carries the operator-authored `humantime` string verbatim
181    /// (e.g. `"1h"`, `"30m"`) so the reason surfaces the spec field as
182    /// it was written, not as it parsed. `elapsed` is the wall-clock
183    /// distance from `metadata.creation_timestamp` at evaluation time.
184    TtlExpired { ttl: String, elapsed: Duration },
185}
186
187impl TerminateReason {
188    /// Discriminator projection — strips the payload, yielding the
189    /// closed-set kind. Used by the reason-kind sweep tests and by any
190    /// future consumer that wants to group reasons by cause without
191    /// pattern-matching the full payload (e.g. metrics labels, future
192    /// `status.conditions` reason-keys).
193    pub const fn kind(&self) -> TerminateReasonKind {
194        match self {
195            Self::TeardownPolicy { .. } => TerminateReasonKind::TeardownPolicy,
196            Self::TtlExpired { .. } => TerminateReasonKind::TtlExpired,
197        }
198    }
199}
200
201impl fmt::Display for TerminateReason {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        // LOAD-BEARING CONTRACT: the strings produced here are the
204        // operator-visible reasons the reconciler stamps onto
205        // `status.message` and `status.conditions[…].message`. They
206        // must match the pre-lift `format!(…)` output byte-for-byte
207        // so existing alerts, dashboards, and operator runbooks keep
208        // matching. Pinned by `terminate_reason_display_matches_pre_lift`.
209        match self {
210            Self::TeardownPolicy { policy, phase } => {
211                write!(
212                    f,
213                    "ephemeral lifetime: teardown_policy={} fired on {}",
214                    policy.as_str(),
215                    phase.as_str(),
216                )
217            }
218            Self::TtlExpired { ttl, elapsed } => {
219                write!(
220                    f,
221                    "ephemeral lifetime: ttl={} expired (elapsed={}s)",
222                    ttl,
223                    elapsed.as_secs(),
224                )
225            }
226        }
227    }
228}
229
230/// The closed set of [`TerminateReason`] kinds — the discriminator
231/// view, payload-stripped, that sibling closed-set enums in this
232/// crate carry (see [`ProcessPhase`], [`TeardownPolicy`]).
233///
234/// Drives the `as_str` / Display / `FromStr` triad over [`Self::ALL`] so
235/// a new variant added with an `ALL` entry automatically extends the
236/// parser, the canonical wire-format projection, and any future
237/// metrics-label / `status.conditions[].reason` enumeration that needs
238/// to enumerate the reason categories.
239#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
240#[closed_set(via = "as_str", display, generate_unknown)]
241pub enum TerminateReasonKind {
242    TeardownPolicy,
243    TtlExpired,
244}
245
246impl TerminateReasonKind {
247    /// The closed set — single source of truth for `as_str` / Display /
248    /// `FromStr`. The `[Self; 2]` array literal forces the arity so a
249    /// third variant added without an `ALL` entry fails at the type
250    /// level before the test sweep below runs.
251    pub const ALL: [Self; 2] = [Self::TeardownPolicy, Self::TtlExpired];
252
253    /// Canonical PascalCase wire-format projection. Mirrors the
254    /// `tatara-process` PascalCase idiom used by every other closed-set
255    /// enum's `as_str` projection (e.g. [`ProcessPhase::as_str`],
256    /// [`TeardownPolicy::as_str`]). A future `status.conditions[].reason`
257    /// field reads this projection directly.
258    pub const fn as_str(self) -> &'static str {
259        match self {
260            Self::TeardownPolicy => "TeardownPolicy",
261            Self::TtlExpired => "TtlExpired",
262        }
263    }
264}
265
266// `impl fmt::Display for TerminateReasonKind` + `impl FromStr for
267// TerminateReasonKind` + `impl tatara_lisp::ClosedSet for
268// TerminateReasonKind` + `pub struct UnknownTerminateReasonKind(pub
269// String)` are generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
270// `#[closed_set(via = "as_str", display, generate_unknown)]` on the
271// enum declaration above. The auto-derived label `"terminate reason
272// kind"` matches the prior hand-rolled `#[error("unknown terminate
273// reason kind: {0}")]` verbatim. The inherent `as_str` projection
274// stays load-bearing — the PascalCase wire-format the
275// `crate::lifetime_clock::evaluate` decision-projection's emitted
276// reason reads — while the trait method `label` gives generic
277// consumers a STABLE name across the workspace-wide closed-set
278// implementors.
279
280/// Inspect a Process at the given current phase and return whether the
281/// ephemeral lifetime clock fires now.
282///
283/// `now` is injected so unit tests can drive the clock deterministically.
284pub fn evaluate(
285    process: &Process,
286    current_phase: ProcessPhase,
287    now: DateTime<Utc>,
288) -> AutoTerminate {
289    // Closed-set projection: ambiguous → no-op; permanent → no-op;
290    // ephemeral → fall through to teardown / TTL checks. ONE
291    // `Process::resolved_ephemeral` gate — the compound spec-projection
292    // primitive on `impl Process` that owns the ambiguity-aware
293    // `variant().ok() + as_ephemeral` chain — replaces the previous
294    // 4-step `process.spec.lifetime.resolved_ephemeral()` walk and
295    // shares the primitive with `requeue_with_ttl` below AND with
296    // `tatara-reconciler::render::render_export_jobs` (which pre-
297    // lift walked the naked `.spec.lifetime.ephemeral.as_ref()`
298    // raw-field access that disagreed on the ambiguous corner).
299    let Some(ephemeral) = process.resolved_ephemeral() else {
300        return AutoTerminate::Skip;
301    };
302
303    // 1. Teardown policy on terminal phases — ONE typed dispatch over
304    //    `(TeardownPolicy, ProcessPhase)` replaces the previous pair of
305    //    near-identical Attested/Failed branches. Non-terminal phases
306    //    short-circuit inside `should_teardown_on`. The reason is the
307    //    typed `TerminateReason::TeardownPolicy` variant whose `Display`
308    //    composes the operator-visible string against the canonical
309    //    PascalCase projection (`TeardownPolicy::as_str` +
310    //    `ProcessPhase::as_str`), not a free-form template.
311    if ephemeral.teardown_policy.should_teardown_on(current_phase) {
312        return AutoTerminate::Now {
313            reason: TerminateReason::TeardownPolicy {
314                policy: ephemeral.teardown_policy,
315                phase: current_phase,
316            },
317        };
318    }
319
320    // 2. TTL expiry — applies in any non-terminal phase.
321    //    The creation-anchor probe rides through the ONE substrate
322    //    `Process::created_at` primitive, sibling to the same-corner
323    //    requeue-budget picker in `requeue_with_ttl` below and the
324    //    stable-name claim-arbiter tie-break seed in
325    //    `tatara-reconciler::table_controller`. Post-lift the two
326    //    consumers here + downstream share the ONE
327    //    `Option<DateTime<Utc>>` return shape.
328    if !is_terminal_or_exit(current_phase) {
329        if let Some(creation) = process.created_at() {
330            if let Ok(ttl) = humantime::parse_duration(&ephemeral.ttl) {
331                let elapsed = now.signed_duration_since(creation).to_std().ok();
332                if let Some(elapsed) = elapsed {
333                    if elapsed >= ttl {
334                        return AutoTerminate::Now {
335                            reason: TerminateReason::TtlExpired {
336                                ttl: ephemeral.ttl.clone(),
337                                elapsed,
338                            },
339                        };
340                    }
341                }
342            }
343        }
344    }
345
346    AutoTerminate::Skip
347}
348
349/// Phases past which TTL cannot meaningfully fire — the SIGTERM path
350/// is already in progress.
351fn is_terminal_or_exit(p: ProcessPhase) -> bool {
352    matches!(
353        p,
354        ProcessPhase::Exiting | ProcessPhase::Zombie | ProcessPhase::Reaped
355    )
356}
357
358/// Sleep budget the controller should requeue with for a Process whose
359/// `evaluate()` returned `Skip` — picks the smaller of HEARTBEAT and
360/// TTL-remaining so we don't oversleep past expiry.
361pub fn requeue_with_ttl(process: &Process, now: DateTime<Utc>, default: Duration) -> Duration {
362    // Shared `Process::resolved_ephemeral` projection with
363    // [`evaluate`] — the "give me only the unambiguous ephemeral case"
364    // compound-lift primitive on `impl Process` that composes through
365    // `impl Lifetime`'s `resolved_ephemeral` and closes drift with the
366    // export-Job render arm at ONE substrate site.
367    let Some(e) = process.resolved_ephemeral() else {
368        return default;
369    };
370    // Creation-anchor probe rides through the ONE substrate
371    // `Process::created_at` primitive (sibling to the TTL-expiry gate
372    // in `evaluate` above); the `let-else` short-circuits on the
373    // missing-slot corner to the caller's `default` sleep budget.
374    let Some(creation) = process.created_at() else {
375        return default;
376    };
377    let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
378        return default;
379    };
380    let elapsed = match now.signed_duration_since(creation).to_std() {
381        Ok(d) => d,
382        Err(_) => return default,
383    };
384    let remaining = ttl.checked_sub(elapsed).unwrap_or(Duration::from_secs(0));
385    // Never sleep less than 1s; never longer than the default heartbeat.
386    let pick = std::cmp::min(default, remaining);
387    std::cmp::max(pick, Duration::from_secs(1))
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::classification::{Classification, ConvergencePointType, SubstrateType};
394    use crate::crd::ProcessSpec;
395    use crate::intent::{AplicacaoIntent, Intent};
396    use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
397    use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
398
399    fn ephemeral_process(ttl: &str, teardown: TeardownPolicy, age_secs: i64) -> Process {
400        let spec = ProcessSpec {
401            identity: Default::default(),
402            classification: Classification {
403                point_type: ConvergencePointType::Gate,
404                substrate: SubstrateType::Compute,
405                horizon: Default::default(),
406                calm: Default::default(),
407                data_classification: Default::default(),
408            },
409            intent: Intent {
410                aplicacao: Some(AplicacaoIntent {
411                    chart_ref: "oci://x".into(),
412                    version: "1".into(),
413                    profile: String::new(),
414                    values_overlay: serde_json::Value::Null,
415                    release_name: None,
416                    target_namespace: None,
417                    install_timeout: None,
418                }),
419                ..Intent::default()
420            },
421            boundary: Default::default(),
422            compliance: Default::default(),
423            depends_on: vec![],
424            signals: Default::default(),
425            lifetime: Lifetime {
426                ephemeral: Some(EphemeralLifetime {
427                    ttl: ttl.into(),
428                    teardown_policy: teardown,
429                    max_concurrent: 1,
430                    exports: vec![],
431                }),
432                ..Lifetime::default()
433            },
434            routing: None,
435            encapsulates: None,
436            suspended: false,
437        };
438        let mut p = Process::new("e", spec);
439        p.metadata.namespace = Some("ns".into());
440        let creation = Utc::now() - chrono::Duration::seconds(age_secs);
441        p.metadata.creation_timestamp = Some(Time(creation));
442        p
443    }
444
445    fn permanent_process() -> Process {
446        let spec = ProcessSpec {
447            identity: Default::default(),
448            classification: Classification {
449                point_type: ConvergencePointType::Gate,
450                substrate: SubstrateType::Compute,
451                horizon: Default::default(),
452                calm: Default::default(),
453                data_classification: Default::default(),
454            },
455            intent: Intent {
456                aplicacao: Some(AplicacaoIntent {
457                    chart_ref: "oci://x".into(),
458                    version: "1".into(),
459                    profile: String::new(),
460                    values_overlay: serde_json::Value::Null,
461                    release_name: None,
462                    target_namespace: None,
463                    install_timeout: None,
464                }),
465                ..Intent::default()
466            },
467            boundary: Default::default(),
468            compliance: Default::default(),
469            depends_on: vec![],
470            signals: Default::default(),
471            lifetime: Lifetime::default(),
472            routing: None,
473            encapsulates: None,
474            suspended: false,
475        };
476        Process::new("e", spec)
477    }
478
479    #[test]
480    fn permanent_never_auto_terminates() {
481        let p = permanent_process();
482        for phase in [
483            ProcessPhase::Pending,
484            ProcessPhase::Execing,
485            ProcessPhase::Running,
486            ProcessPhase::Attested,
487            ProcessPhase::Failed,
488        ] {
489            assert_eq!(evaluate(&p, phase, Utc::now()), AutoTerminate::Skip);
490        }
491    }
492
493    #[test]
494    fn always_teardown_fires_on_attested_and_failed() {
495        let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
496        let now = Utc::now();
497        assert!(matches!(
498            evaluate(&p, ProcessPhase::Attested, now),
499            AutoTerminate::Now { .. }
500        ));
501        assert!(matches!(
502            evaluate(&p, ProcessPhase::Failed, now),
503            AutoTerminate::Now { .. }
504        ));
505        assert_eq!(
506            evaluate(&p, ProcessPhase::Running, now),
507            AutoTerminate::Skip
508        );
509    }
510
511    #[test]
512    fn on_attested_only_fires_on_attested() {
513        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
514        let now = Utc::now();
515        assert!(matches!(
516            evaluate(&p, ProcessPhase::Attested, now),
517            AutoTerminate::Now { .. }
518        ));
519        assert_eq!(evaluate(&p, ProcessPhase::Failed, now), AutoTerminate::Skip);
520    }
521
522    #[test]
523    fn on_failed_only_fires_on_failed() {
524        let p = ephemeral_process("1h", TeardownPolicy::OnFailed, 60);
525        let now = Utc::now();
526        assert_eq!(
527            evaluate(&p, ProcessPhase::Attested, now),
528            AutoTerminate::Skip
529        );
530        assert!(matches!(
531            evaluate(&p, ProcessPhase::Failed, now),
532            AutoTerminate::Now { .. }
533        ));
534    }
535
536    #[test]
537    fn never_skips_phase_terminations_but_still_honors_ttl() {
538        let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
539        let now = Utc::now();
540        // TTL elapsed → TTL fires regardless of policy.
541        assert!(matches!(
542            evaluate(&p, ProcessPhase::Running, now),
543            AutoTerminate::Now { .. }
544        ));
545        // But not on a terminal phase (already exiting).
546        assert_eq!(
547            evaluate(&p, ProcessPhase::Exiting, now),
548            AutoTerminate::Skip
549        );
550    }
551
552    #[test]
553    fn ttl_not_yet_elapsed_is_skip() {
554        let p = ephemeral_process("1h", TeardownPolicy::Never, 60);
555        assert_eq!(
556            evaluate(&p, ProcessPhase::Running, Utc::now()),
557            AutoTerminate::Skip
558        );
559    }
560
561    /// REASON-STRING CONTRACT: the operator-visible reason composes
562    /// the canonical PascalCase projection of `TeardownPolicy` and
563    /// `ProcessPhase` (via Display) rather than the Debug formatting
564    /// used pre-lift. A future variant rename of either enum updates
565    /// the reason string at ONE site (the `as_str` arm) instead of
566    /// drifting between the typed surface and the operator log.
567    #[test]
568    fn teardown_reason_string_uses_canonical_projection() {
569        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
570        match evaluate(&p, ProcessPhase::Attested, Utc::now()) {
571            AutoTerminate::Now { reason } => {
572                let rendered = reason.to_string();
573                assert!(
574                    rendered.contains("teardown_policy=OnAttested"),
575                    "expected canonical PascalCase policy, got: {rendered}",
576                );
577                assert!(
578                    rendered.contains("fired on Attested"),
579                    "expected canonical PascalCase phase, got: {rendered}",
580                );
581            }
582            other => panic!("expected AutoTerminate::Now, got {other:?}"),
583        }
584
585        let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
586        match evaluate(&p, ProcessPhase::Failed, Utc::now()) {
587            AutoTerminate::Now { reason } => {
588                let rendered = reason.to_string();
589                assert!(rendered.contains("teardown_policy=Always"));
590                assert!(rendered.contains("fired on Failed"));
591            }
592            other => panic!("expected AutoTerminate::Now, got {other:?}"),
593        }
594    }
595
596    // ── TerminateReason / TerminateReasonKind closed-set contracts ────
597
598    /// BYTE-FOR-BYTE PRE-LIFT CONTRACT: the Display impl on
599    /// `TerminateReason` must produce the exact string the pre-lift
600    /// inline `format!(…)` calls produced. Existing alerts, dashboards,
601    /// and operator runbooks that grep `status.message` for these
602    /// substrings keep matching. A future variant rename of
603    /// `TeardownPolicy` / `ProcessPhase` updates the rendered string
604    /// here automatically (Display reads `as_str` projection), but the
605    /// template — `"ephemeral lifetime: teardown_policy={} fired on {}"`
606    /// vs `"ephemeral lifetime: ttl={} expired (elapsed={}s)"` — is
607    /// pinned at the Display site.
608    #[test]
609    fn terminate_reason_display_matches_pre_lift() {
610        // TeardownPolicy variant — every combination of policy × phase
611        // sweeps both PascalCase projections.
612        for policy in TeardownPolicy::ALL {
613            for phase in ProcessPhase::ALL {
614                let reason = TerminateReason::TeardownPolicy { policy, phase };
615                let expected = format!(
616                    "ephemeral lifetime: teardown_policy={} fired on {}",
617                    policy.as_str(),
618                    phase.as_str(),
619                );
620                assert_eq!(
621                    reason.to_string(),
622                    expected,
623                    "Display drifted for ({policy:?}, {phase:?})",
624                );
625            }
626        }
627        // TtlExpired variant — pins the ttl-verbatim + elapsed-secs
628        // template against representative humantime strings the
629        // EphemeralLifetime.ttl field accepts.
630        for (ttl, elapsed_secs) in [("1h", 0u64), ("30m", 60), ("90s", 100), ("5m30s", 3600)] {
631            let reason = TerminateReason::TtlExpired {
632                ttl: ttl.to_string(),
633                elapsed: Duration::from_secs(elapsed_secs),
634            };
635            assert_eq!(
636                reason.to_string(),
637                format!("ephemeral lifetime: ttl={ttl} expired (elapsed={elapsed_secs}s)"),
638            );
639        }
640    }
641
642    /// Reason `kind()` projection — closed-set match so a future
643    /// variant triggers exhaustiveness checking at the projection
644    /// site rather than silently bucketing through a wildcard. Every
645    /// variant's `kind()` matches its `TerminateReasonKind` peer.
646    #[test]
647    fn terminate_reason_kind_truth_table() {
648        assert_eq!(
649            TerminateReason::TeardownPolicy {
650                policy: TeardownPolicy::Always,
651                phase: ProcessPhase::Attested,
652            }
653            .kind(),
654            TerminateReasonKind::TeardownPolicy,
655        );
656        assert_eq!(
657            TerminateReason::TtlExpired {
658                ttl: "1h".to_string(),
659                elapsed: Duration::from_secs(0),
660            }
661            .kind(),
662            TerminateReasonKind::TtlExpired,
663        );
664    }
665
666    /// `ALL` is the source of truth; a variant added without an `ALL`
667    /// entry fails here (uniqueness check) before any sweep test below
668    /// runs. Arity is asserted by the array type itself (`[Self; 2]`).
669    /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
670    /// [`TerminateReasonKind`] — pins the structural three-plus-one
671    /// (`ALL` is non-empty, every variant round-trips through
672    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
673    /// outside the closed set) at ONE call site. Replaces the
674    /// hand-derived `terminate_reason_kind_all_is_unique_and_complete`
675    /// + `terminate_reason_kind_roundtrip_via_as_str` + the empty-input
676    /// arm of `unknown_terminate_reason_kind_errors`. `FromStr`
677    /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
678    /// so this helper exercises the same code path the lifetime-clock
679    /// evaluator hits when parsing a typed reason back out of a
680    /// `status.conditions[].reason` slot.
681    #[test]
682    fn terminate_reason_kind_is_well_formed_closed_set() {
683        tatara_closed_set::assert_closed_set_well_formed::<TerminateReasonKind>();
684    }
685
686    /// `Display` IS `as_str` — pinning this lets future callers reach
687    /// for either projection without drift.
688    #[test]
689    fn terminate_reason_kind_display_matches_as_str() {
690        crate::tagged_union::assert_display_matches_label::<TerminateReasonKind>();
691    }
692
693    /// Every kind's `as_str` is in canonical PascalCase. The first
694    /// character is uppercase; no whitespace; no separators. The
695    /// `tatara-process` PascalCase idiom holds at one test site.
696    #[test]
697    fn terminate_reason_kind_as_str_is_pascal_case() {
698        for kind in TerminateReasonKind::ALL {
699            let s = kind.as_str();
700            assert!(!s.is_empty(), "as_str empty for {kind:?}");
701            assert!(
702                s.chars().next().unwrap().is_ascii_uppercase(),
703                "as_str not PascalCase for {kind:?}: {s}",
704            );
705            assert!(
706                !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
707                "as_str carries separator for {kind:?}: {s}",
708            );
709        }
710    }
711
712    /// `FromStr` rejects strings outside the canonical projection
713    /// (lowercased / typo / cross-axis-leaked) and echoes the input
714    /// verbatim. The empty-string arm is covered by
715    /// `terminate_reason_kind_is_well_formed_closed_set` via the
716    /// [`tatara_lisp::ClosedSet`] contract; the verbatim-echo arms
717    /// stay here because they pin the `UnknownTerminateReasonKind`
718    /// newtype payload contract the trait's `make_unknown` cannot
719    /// see. Cross-axis inputs (ProcessPhase / TeardownPolicy variant
720    /// names) MUST fail — `TerminateReasonKind` is its own axis, not
721    /// a transparent reflection of either.
722    #[test]
723    fn unknown_terminate_reason_kind_errors() {
724        use std::str::FromStr;
725        for bad in [
726            "teardownPolicy",
727            "TEARDOWN_POLICY",
728            "Teardown",
729            "TtlExpire",
730            "ttl_expired",
731            "ttlExpired",
732            // Cross-axis-leaked — must NOT cross axes.
733            "Attested",
734            "Failed",
735            "Always",
736            "OnAttested",
737            "OnFailed",
738            "Never",
739            "Permanent",
740            "Ephemeral",
741        ] {
742            let err = TerminateReasonKind::from_str(bad).unwrap_err();
743            assert_eq!(err.0, bad, "error payload should echo input verbatim");
744        }
745    }
746
747    /// The reason `evaluate` returns under teardown maps to
748    /// `TerminateReasonKind::TeardownPolicy` AND its payload reflects
749    /// the spec's `(teardown_policy, current_phase)` verbatim — the
750    /// typed surface IS the source of truth, not an inline format
751    /// template. A future consumer that wants to group reasons by
752    /// kind in metrics labels reads `reason.kind()`, not a substring
753    /// match.
754    #[test]
755    fn evaluate_typed_reason_carries_teardown_payload() {
756        for (policy, phase) in [
757            (TeardownPolicy::Always, ProcessPhase::Attested),
758            (TeardownPolicy::Always, ProcessPhase::Failed),
759            (TeardownPolicy::OnAttested, ProcessPhase::Attested),
760            (TeardownPolicy::OnFailed, ProcessPhase::Failed),
761        ] {
762            let p = ephemeral_process("1h", policy, 60);
763            match evaluate(&p, phase, Utc::now()) {
764                AutoTerminate::Now { reason } => {
765                    assert_eq!(reason.kind(), TerminateReasonKind::TeardownPolicy);
766                    assert_eq!(
767                        reason,
768                        TerminateReason::TeardownPolicy { policy, phase },
769                        "typed payload drift for ({policy:?}, {phase:?})",
770                    );
771                }
772                other => {
773                    panic!("expected AutoTerminate::Now for ({policy:?}, {phase:?}), got {other:?}",)
774                }
775            }
776        }
777    }
778
779    /// TTL expiry returns a `TtlExpired` reason whose `ttl` field is
780    /// the operator-authored humantime string verbatim (NOT the
781    /// parsed `Duration`'s pretty-print) and whose `elapsed` is the
782    /// wall-clock distance. Pinned here so a future evaluator change
783    /// that re-formats the ttl through `humantime::format_duration`
784    /// would fail.
785    #[test]
786    fn evaluate_typed_reason_carries_ttl_payload() {
787        let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
788        let now = Utc::now();
789        match evaluate(&p, ProcessPhase::Running, now) {
790            AutoTerminate::Now { reason } => {
791                assert_eq!(reason.kind(), TerminateReasonKind::TtlExpired);
792                match reason {
793                    TerminateReason::TtlExpired { ttl, elapsed } => {
794                        assert_eq!(ttl, "30s", "ttl should be verbatim spec string");
795                        assert!(
796                            elapsed >= Duration::from_secs(30),
797                            "elapsed should be at least the ttl",
798                        );
799                    }
800                    other => panic!("expected TtlExpired, got {other:?}"),
801                }
802            }
803            other => panic!("expected AutoTerminate::Now, got {other:?}"),
804        }
805    }
806
807    // ── AutoTerminate / AutoTerminateKind closed-set contracts ────────
808
809    /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
810    /// [`AutoTerminateKind`] — pins the structural three-plus-one
811    /// (`ALL` is non-empty, every variant round-trips through
812    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
813    /// outside the closed set) at ONE call site. Replaces the
814    /// hand-derived uniqueness sweep in
815    /// `auto_terminate_kind_kind_projection_is_exhaustive_over_all`'s
816    /// pre-lift form + the `auto_terminate_kind_roundtrip_via_as_str`
817    /// hand-rolled sweep + the empty-input arm of
818    /// `unknown_auto_terminate_kind_errors`. `FromStr` delegates to
819    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
820    /// helper exercises the same code path the lifetime-clock
821    /// evaluator hits when parsing a typed kind back out of a
822    /// `status.conditions[].reason` slot.
823    #[test]
824    fn auto_terminate_kind_is_well_formed_closed_set() {
825        tatara_closed_set::assert_closed_set_well_formed::<AutoTerminateKind>();
826    }
827
828    /// Every entry in `ALL` is reachable through a concrete
829    /// [`AutoTerminate`] value via [`AutoTerminate::kind`] — the
830    /// projection is exhaustive across the variant set. Pre-lift this
831    /// pin was bundled with a uniqueness HashSet sweep that
832    /// [`auto_terminate_kind_is_well_formed_closed_set`] now covers
833    /// generically through the [`tatara_lisp::ClosedSet`] contract;
834    /// post-lift this test keeps only the domain-specific
835    /// `kind()`-exhaustiveness contract (the (variant-name →
836    /// payload-stripped kind) binding the [`AutoTerminate`] surface
837    /// projects through). A future third payload-carrying
838    /// `AutoTerminate` variant updates this pin AND
839    /// [`AutoTerminate::kind`]'s exhaustiveness match together,
840    /// exhaustively checked by the compiler.
841    #[test]
842    fn auto_terminate_kind_kind_projection_is_exhaustive_over_all() {
843        let by_all: std::collections::HashSet<_> = AutoTerminateKind::ALL.iter().copied().collect();
844        let sample_reason = TerminateReason::TtlExpired {
845            ttl: "1h".into(),
846            elapsed: Duration::from_secs(0),
847        };
848        let by_concrete: std::collections::HashSet<_> = [
849            AutoTerminate::Skip.kind(),
850            AutoTerminate::Now {
851                reason: sample_reason,
852            }
853            .kind(),
854        ]
855        .into_iter()
856        .collect();
857        assert_eq!(
858            by_concrete, by_all,
859            "kind() projection not exhaustive over ALL"
860        );
861    }
862
863    /// BYTE-EXACT canonical wire-format pin — renaming either of the two
864    /// canonical strings is a wire-format change that fails this test
865    /// FIRST so it stays a deliberate change, not a silent rename that
866    /// drifts existing alerts / dashboards / operator runbooks.
867    #[test]
868    fn auto_terminate_kind_canonical_names_pinned() {
869        assert_eq!(AutoTerminateKind::Skip.as_str(), "Skip");
870        assert_eq!(AutoTerminateKind::Now.as_str(), "Now");
871    }
872
873    /// Every kind's `as_str` is in canonical PascalCase. The first
874    /// character is uppercase; no whitespace; no separators. The
875    /// `tatara-process` PascalCase idiom holds at one test site.
876    #[test]
877    fn auto_terminate_kind_as_str_is_pascal_case() {
878        for kind in AutoTerminateKind::ALL {
879            let s = kind.as_str();
880            assert!(!s.is_empty(), "as_str empty for {kind:?}");
881            assert!(
882                s.chars().next().unwrap().is_ascii_uppercase(),
883                "as_str not PascalCase for {kind:?}: {s}",
884            );
885            assert!(
886                !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
887                "as_str carries separator for {kind:?}: {s}",
888            );
889        }
890    }
891
892    /// `Display` IS `as_str` — pinning this lets future callers reach
893    /// for either projection without drift.
894    #[test]
895    fn auto_terminate_kind_display_matches_as_str() {
896        crate::tagged_union::assert_display_matches_label::<AutoTerminateKind>();
897    }
898
899    /// `FromStr` rejects strings outside the canonical projection
900    /// (lowercased / typo / cross-axis-leaked) and echoes the input
901    /// verbatim. The empty-string arm AND the round-trip sweep are
902    /// covered by `auto_terminate_kind_is_well_formed_closed_set` via
903    /// the [`tatara_lisp::ClosedSet`] contract; the cases here pin the
904    /// `UnknownAutoTerminateKind` newtype payload contract the
905    /// trait's `make_unknown` cannot see. Cross-axis inputs
906    /// (ProcessPhase / TeardownPolicy / TerminateReasonKind variant
907    /// names) MUST fail — `AutoTerminateKind` is its own axis, not
908    /// a transparent reflection of any sibling enum.
909    #[test]
910    fn unknown_auto_terminate_kind_errors() {
911        use std::str::FromStr;
912        for bad in [
913            "skip",
914            "now",
915            "SKIP",
916            "NOW",
917            "S",
918            "N",
919            "no-op",
920            "terminate",
921            // Cross-axis-leaked — must NOT cross axes.
922            "Attested",
923            "Failed",
924            "TeardownPolicy",
925            "TtlExpired",
926            "Always",
927            "Permanent",
928            "Ephemeral",
929        ] {
930            let err = AutoTerminateKind::from_str(bad).unwrap_err();
931            assert_eq!(err.0, bad, "error payload should echo input verbatim");
932        }
933    }
934
935    /// `reason()` projection: `Now { reason }` returns `Some(&reason)`,
936    /// `Skip` returns `None`. The (variant-name → payload-field)
937    /// binding lives at ONE site so a future third payload-carrying
938    /// variant updates every consumer through this method's
939    /// exhaustiveness check rather than scattering destructures across
940    /// the call graph.
941    #[test]
942    fn auto_terminate_reason_projection() {
943        assert!(AutoTerminate::Skip.reason().is_none());
944
945        let reason = TerminateReason::TtlExpired {
946            ttl: "1h".into(),
947            elapsed: Duration::from_secs(0),
948        };
949        let now = AutoTerminate::Now {
950            reason: reason.clone(),
951        };
952        assert_eq!(now.reason(), Some(&reason));
953
954        let teardown = TerminateReason::TeardownPolicy {
955            policy: TeardownPolicy::OnAttested,
956            phase: ProcessPhase::Attested,
957        };
958        let now = AutoTerminate::Now {
959            reason: teardown.clone(),
960        };
961        assert_eq!(now.reason(), Some(&teardown));
962    }
963
964    /// `is_now` / `is_skip` are exact complements over the closed set —
965    /// `is_now ⊕ is_skip = true` for every variant. Locks the predicate
966    /// pair so a future third variant that's neither Skip nor Now must
967    /// extend BOTH predicates in lockstep (or this contract fails).
968    #[test]
969    fn auto_terminate_predicate_pair_is_exhaustive_complement() {
970        let reason = TerminateReason::TtlExpired {
971            ttl: "1h".into(),
972            elapsed: Duration::from_secs(0),
973        };
974        for decision in [
975            AutoTerminate::Skip,
976            AutoTerminate::Now {
977                reason: reason.clone(),
978            },
979        ] {
980            assert_ne!(
981                decision.is_now(),
982                decision.is_skip(),
983                "predicate pair drift for {decision:?}",
984            );
985            // The kind projection agrees with each predicate.
986            assert_eq!(decision.is_now(), decision.kind() == AutoTerminateKind::Now);
987            assert_eq!(
988                decision.is_skip(),
989                decision.kind() == AutoTerminateKind::Skip
990            );
991            // `reason()` agrees with `is_now`.
992            assert_eq!(decision.reason().is_some(), decision.is_now());
993        }
994    }
995
996    /// The `kind()` projection on the typed result of `evaluate` agrees
997    /// with the behavioural expectation: ephemeral-on-Attested with an
998    /// OnAttested policy returns `Now`, permanent never does. Closes
999    /// the loop between the closed-set view and the live decision so
1000    /// any future kind-keyed metrics label (e.g.
1001    /// `tatara_lifetime_clock_decisions_total{kind="Now"}`) reads the
1002    /// typed projection rather than the inline destructure.
1003    #[test]
1004    fn evaluate_decision_kind_agrees_with_runtime_behaviour() {
1005        let p = permanent_process();
1006        for phase in [
1007            ProcessPhase::Pending,
1008            ProcessPhase::Running,
1009            ProcessPhase::Attested,
1010            ProcessPhase::Failed,
1011        ] {
1012            let decision = evaluate(&p, phase, Utc::now());
1013            assert_eq!(
1014                decision.kind(),
1015                AutoTerminateKind::Skip,
1016                "permanent Process must always Skip; got Now for phase={phase:?}",
1017            );
1018            assert!(decision.reason().is_none());
1019        }
1020
1021        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
1022        let now = Utc::now();
1023        assert_eq!(
1024            evaluate(&p, ProcessPhase::Attested, now).kind(),
1025            AutoTerminateKind::Now,
1026        );
1027        assert_eq!(
1028            evaluate(&p, ProcessPhase::Running, now).kind(),
1029            AutoTerminateKind::Skip,
1030        );
1031    }
1032
1033    #[test]
1034    fn requeue_picks_min_of_default_and_remaining() {
1035        let p = ephemeral_process("5m", TeardownPolicy::Always, 60);
1036        let now = Utc::now();
1037        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1038        // 5m total - 60s elapsed = 240s remaining; default 30s wins.
1039        assert_eq!(d, Duration::from_secs(30));
1040
1041        let p = ephemeral_process("90s", TeardownPolicy::Always, 80);
1042        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1043        // 90s - 80s = 10s remaining; remaining wins.
1044        assert!(d <= Duration::from_secs(11) && d >= Duration::from_secs(9));
1045
1046        let p = ephemeral_process("90s", TeardownPolicy::Always, 91);
1047        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1048        // Already past TTL — clamp to 1s, not 0.
1049        assert_eq!(d, Duration::from_secs(1));
1050    }
1051}