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            // TTL parse rides through the ONE substrate primitive
331            // [`crate::lifetime::EphemeralLifetime::ttl_duration`] —
332            // the `humantime::parse_duration(&<eph>.ttl).ok()` chain
333            // pre-lift hand-authored at TWO workspace-wide sites past
334            // the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
335            // (peer at [`requeue_with_ttl`] below). Post-lift both
336            // consumers share ONE typed owner returning the same
337            // `Option<Duration>` shape [`crate::time::elapsed_since`]
338            // returns, so the `elapsed >= ttl` comparator lands with
339            // both operands on the same axis; a future TTL-side
340            // normalization (per-fleet minimum floor, canonical
341            // unit-normalization, warn-log on unparseable strings)
342            // lands at ONE substrate site.
343            if let Some(ttl) = ephemeral.ttl_duration() {
344                // The `(now, creation) → Option<std::time::Duration>`
345                // projection rides through the ONE substrate primitive
346                // [`crate::time::elapsed_since`], sibling to the same-
347                // chain sleep-budget picker in [`requeue_with_ttl`]
348                // below and the pool-staleness gate in
349                // `tatara-pool-reconciler::pool_decide`. Pre-lift each
350                // of the three sites hand-authored `now
351                // .signed_duration_since(<anchor>).to_std().ok()` past
352                // the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold;
353                // post-lift each routes through ONE typed owner and a
354                // future normalization (monotonic-clock cross-check,
355                // per-fleet skew tolerance, subsecond truncation) lands
356                // at ONE substrate site.
357                if let Some(elapsed) = crate::time::elapsed_since(now, creation) {
358                    if elapsed >= ttl {
359                        return AutoTerminate::Now {
360                            reason: TerminateReason::TtlExpired {
361                                ttl: ephemeral.ttl.clone(),
362                                elapsed,
363                            },
364                        };
365                    }
366                }
367            }
368        }
369    }
370
371    AutoTerminate::Skip
372}
373
374/// Wall-clock-anchored peer of [`evaluate`] — pins the `now` argument
375/// to [`chrono::Utc::now`] so every production reconciler tick reads a
376/// single-shape 2-arg call rather than restating the wall-clock
377/// projection at each phase handler.
378///
379/// # Why it exists
380///
381/// Pre-lift the 3-arg `evaluate(p, ProcessPhase::X, chrono::Utc::now())`
382/// chain was hand-authored at THREE sites past the ★★ PRIME-DIRECTIVE
383/// ≥ 2 duplication threshold in `tatara-reconciler::phase_machine`,
384/// each pairing an ephemeral-lifetime clock check against wall-clock
385/// time inside a `handle_<phase>` async fn:
386///
387/// * `handle_running` — VERIFY-phase clock check on `ProcessPhase::
388///   Running`; a `Now` verdict force-transitions to `Exiting`
389///   regardless of postcondition state.
390/// * `handle_attested` — ATTEST-heartbeat clock check on `ProcessPhase::
391///   Attested`; a `Now` verdict routes through Releasing when exports
392///   are declared, otherwise straight to `Exiting`.
393/// * `handle_failed` — Failed-phase clock check on `ProcessPhase::
394///   Failed`; a `Now` verdict routes through Releasing when post-mortem
395///   exports are declared, otherwise straight to `Zombie`.
396///
397/// All three sites walked the SAME 3-arg call with the SAME
398/// `chrono::Utc::now()` third argument — the wall-clock projection had
399/// no per-callsite variation. Post-lift the three consumers share ONE
400/// substrate owner for the wall-clock-at-tick projection; a future
401/// clock swap (a monotonic clock cross-check, a per-reconciler
402/// injected time source, a test-only override at the production
403/// callsite via feature flag) lands at ONE substrate function and
404/// every phase handler inherits the upgrade mechanically.
405///
406/// The 3-arg [`evaluate`] peer stays load-bearing for test callers —
407/// the injected-`now` shape is what unit tests use to drive the clock
408/// deterministically (every `evaluate(&p, phase, Utc::now())` /
409/// `evaluate(&p, phase, seeded_now)` in this module's own test suite
410/// reads that surface). This peer is production-only: pinning the
411/// wall-clock at the substrate site means no test can accidentally
412/// consume it without the deterministic-clock injection that makes
413/// the test meaningful.
414///
415/// # Invariants
416///
417/// - **Same decision shape:** returns the SAME [`AutoTerminate`] the
418///   3-arg [`evaluate`] returns when passed `chrono::Utc::now()` as the
419///   third argument. This is a delegation, not a re-implementation.
420/// - **Wall-clock read once:** `Utc::now()` is called exactly ONCE per
421///   invocation, at the primitive's body, so a future consumer that
422///   chains two `evaluate_now` calls back-to-back still sees monotonic
423///   `now` reads (each call reads a fresh instant, not a cached one) —
424///   matches the pre-lift shape where each of the three phase
425///   handlers computed its own `chrono::Utc::now()` at its own line.
426///
427/// # `#[must_use]`
428///
429/// Every consumer either destructures the returned `AutoTerminate` at
430/// an `if let AutoTerminate::Now { reason } = …` guard (the three
431/// pre-lift phase-handler shapes) or feeds it into a downstream
432/// dispatcher that gates on `AutoTerminate::is_now`. Dropping the
433/// return means the clock check fired for no observable reason — the
434/// attribute surfaces that as a warning at every call site.
435///
436/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
437/// 3-arg call with `chrono::Utc::now()` as the third argument recurred
438/// at 3 hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
439/// duplication trigger, lifted onto the ONE workspace-wide substrate
440/// owner here). THEORY.md §II.1 invariant 5 (composition preserves
441/// proofs — the wall-clock projection lives at ONE site so a future
442/// clock swap reaches all three consumers through one edit).
443#[must_use]
444pub fn evaluate_now(process: &Process, current_phase: ProcessPhase) -> AutoTerminate {
445    evaluate(process, current_phase, Utc::now())
446}
447
448/// Phases past which TTL cannot meaningfully fire — the SIGTERM path
449/// is already in progress.
450fn is_terminal_or_exit(p: ProcessPhase) -> bool {
451    matches!(
452        p,
453        ProcessPhase::Exiting | ProcessPhase::Zombie | ProcessPhase::Reaped
454    )
455}
456
457/// Sleep budget the controller should requeue with for a Process whose
458/// `evaluate()` returned `Skip` — picks the smaller of HEARTBEAT and
459/// TTL-remaining so we don't oversleep past expiry.
460pub fn requeue_with_ttl(process: &Process, now: DateTime<Utc>, default: Duration) -> Duration {
461    // Shared `Process::resolved_ephemeral` projection with
462    // [`evaluate`] — the "give me only the unambiguous ephemeral case"
463    // compound-lift primitive on `impl Process` that composes through
464    // `impl Lifetime`'s `resolved_ephemeral` and closes drift with the
465    // export-Job render arm at ONE substrate site.
466    let Some(e) = process.resolved_ephemeral() else {
467        return default;
468    };
469    // Creation-anchor probe rides through the ONE substrate
470    // `Process::created_at` primitive (sibling to the TTL-expiry gate
471    // in `evaluate` above); the `let-else` short-circuits on the
472    // missing-slot corner to the caller's `default` sleep budget.
473    let Some(creation) = process.created_at() else {
474        return default;
475    };
476    // Shared TTL-parse projection with [`evaluate`] above — the
477    // `humantime::parse_duration(&<eph>.ttl).ok()` chain rides through
478    // the ONE substrate primitive
479    // [`crate::lifetime::EphemeralLifetime::ttl_duration`]. The
480    // `let-else` short-circuits on the parse-failure corner (typo,
481    // unsupported unit, non-humantime literal on the wire) to the
482    // caller's `default` sleep budget — the same "no ttl data → do
483    // not fire the timed decision" interpretation the TTL-expiry
484    // gate in [`evaluate`] gives to the `None` arm.
485    let Some(ttl) = e.ttl_duration() else {
486        return default;
487    };
488    // Sibling to the TTL-expiry gate in [`evaluate`] above: the
489    // `(now, creation) → Option<std::time::Duration>` projection rides
490    // through the ONE substrate primitive [`crate::time::elapsed_since`].
491    // The `let-else` short-circuits on the negative-anchor corner
492    // (clock skew or a creation timestamp stamped past `now`) to the
493    // caller's `default` sleep budget — the same "no elapsed data → do
494    // not fire the timed decision" interpretation every other consumer
495    // gives to the `None` arm.
496    let Some(elapsed) = crate::time::elapsed_since(now, creation) else {
497        return default;
498    };
499    let remaining = ttl.checked_sub(elapsed).unwrap_or(Duration::from_secs(0));
500    // Never sleep less than 1s; never longer than the default heartbeat.
501    let pick = std::cmp::min(default, remaining);
502    std::cmp::max(pick, Duration::from_secs(1))
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::crd::ProcessSpec;
509    use crate::intent::{AplicacaoIntent, Intent};
510    use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
511
512    fn ephemeral_process(ttl: &str, teardown: TeardownPolicy, age_secs: i64) -> Process {
513        // Struct-update through the ONE substrate composer
514        // `ProcessSpec::gate_compute_defaults` — pre-lift the nine
515        // other slots were hand-authored inline alongside the `intent`
516        // + `lifetime` overrides; post-lift the substrate owns them.
517        let spec = ProcessSpec {
518            intent: Intent {
519                aplicacao: Some(AplicacaoIntent::chart_only("oci://x", "1")),
520                ..Intent::default()
521            },
522            // Routes through the ONE substrate composer
523            // [`Lifetime::ephemeral`] — see the composer's doc-comment
524            // for the full migration rationale.
525            lifetime: Lifetime::ephemeral(EphemeralLifetime {
526                ttl: ttl.into(),
527                teardown_policy: teardown,
528                max_concurrent: 1,
529                exports: vec![],
530            }),
531            ..ProcessSpec::gate_compute_defaults()
532        };
533        let mut p = Process::new("e", spec);
534        p.metadata.namespace = Some("ns".into());
535        // Routes through the ONE substrate primitive `crate::time::
536        // seconds_ago` — one of 21 pre-lift exact-match sites past the
537        // ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold.
538        let creation = crate::time::seconds_ago(age_secs);
539        // Routes through the ONE substrate primitive
540        // `crate::time::creation_stamp_at` — the anchor-explicit peer of
541        // `crate::time::tombstone_at` on the (creation, deletion) axis
542        // of the `ObjectMeta` metadata-Time slots. Pre-lift this site
543        // restated the 5-token `Some(Time(creation))` wire wrap by
544        // hand; post-lift the K8s Time wrap + Option wrap sinks live at
545        // ONE substrate owner alongside the deletion-slot peer.
546        p.metadata.creation_timestamp = crate::time::creation_stamp_at(creation);
547        p
548    }
549
550    fn permanent_process() -> Process {
551        // Struct-update through the ONE substrate composer
552        // `ProcessSpec::gate_compute_defaults` — the ten other slots
553        // (identity / classification / boundary / compliance /
554        // depends_on / signals / lifetime / routing / encapsulates /
555        // suspended) ride the substrate; only `intent` is overridden.
556        let spec = ProcessSpec {
557            intent: Intent {
558                aplicacao: Some(AplicacaoIntent::chart_only("oci://x", "1")),
559                ..Intent::default()
560            },
561            ..ProcessSpec::gate_compute_defaults()
562        };
563        Process::new("e", spec)
564    }
565
566    #[test]
567    fn permanent_never_auto_terminates() {
568        let p = permanent_process();
569        for phase in [
570            ProcessPhase::Pending,
571            ProcessPhase::Execing,
572            ProcessPhase::Running,
573            ProcessPhase::Attested,
574            ProcessPhase::Failed,
575        ] {
576            assert_eq!(evaluate(&p, phase, Utc::now()), AutoTerminate::Skip);
577        }
578    }
579
580    #[test]
581    fn always_teardown_fires_on_attested_and_failed() {
582        let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
583        let now = Utc::now();
584        assert!(matches!(
585            evaluate(&p, ProcessPhase::Attested, now),
586            AutoTerminate::Now { .. }
587        ));
588        assert!(matches!(
589            evaluate(&p, ProcessPhase::Failed, now),
590            AutoTerminate::Now { .. }
591        ));
592        assert_eq!(
593            evaluate(&p, ProcessPhase::Running, now),
594            AutoTerminate::Skip
595        );
596    }
597
598    #[test]
599    fn on_attested_only_fires_on_attested() {
600        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
601        let now = Utc::now();
602        assert!(matches!(
603            evaluate(&p, ProcessPhase::Attested, now),
604            AutoTerminate::Now { .. }
605        ));
606        assert_eq!(evaluate(&p, ProcessPhase::Failed, now), AutoTerminate::Skip);
607    }
608
609    #[test]
610    fn on_failed_only_fires_on_failed() {
611        let p = ephemeral_process("1h", TeardownPolicy::OnFailed, 60);
612        let now = Utc::now();
613        assert_eq!(
614            evaluate(&p, ProcessPhase::Attested, now),
615            AutoTerminate::Skip
616        );
617        assert!(matches!(
618            evaluate(&p, ProcessPhase::Failed, now),
619            AutoTerminate::Now { .. }
620        ));
621    }
622
623    #[test]
624    fn never_skips_phase_terminations_but_still_honors_ttl() {
625        let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
626        let now = Utc::now();
627        // TTL elapsed → TTL fires regardless of policy.
628        assert!(matches!(
629            evaluate(&p, ProcessPhase::Running, now),
630            AutoTerminate::Now { .. }
631        ));
632        // But not on a terminal phase (already exiting).
633        assert_eq!(
634            evaluate(&p, ProcessPhase::Exiting, now),
635            AutoTerminate::Skip
636        );
637    }
638
639    #[test]
640    fn ttl_not_yet_elapsed_is_skip() {
641        let p = ephemeral_process("1h", TeardownPolicy::Never, 60);
642        assert_eq!(
643            evaluate(&p, ProcessPhase::Running, Utc::now()),
644            AutoTerminate::Skip
645        );
646    }
647
648    // ─── evaluate_now substrate pins ────────────────────────────────
649    //
650    // The wall-clock-anchored peer [`evaluate_now`] pins the 3-arg
651    // `evaluate(p, phase, chrono::Utc::now())` chain at ONE substrate
652    // site across THREE consumer callsites in
653    // `tatara-reconciler::phase_machine` (handle_running,
654    // handle_attested, handle_failed). These pins bind the peer at
655    // fail-before-pass-after granularity so a regression that swapped
656    // the delegated clock (a monotonic-clock read, a stale cached
657    // instant, a fixed epoch) or drifted the decision shape (returning
658    // a different `AutoTerminate` variant than the 3-arg peer would on
659    // the same wall-clock instant) surfaces HERE rather than as silent
660    // ephemeral-teardown skew at three phase handlers simultaneously.
661
662    #[test]
663    fn evaluate_now_permanent_process_returns_skip() {
664        // Peer-parity witness on the permanent-process corner: the
665        // 3-arg [`evaluate`] returns `Skip` for a permanent Process on
666        // every phase; the wall-clock-anchored [`evaluate_now`] must
667        // return the SAME `Skip` — the delegation must not silently
668        // trip a teardown branch on a Process that lacks an
669        // `EphemeralLifetime` block at all.
670        let p = permanent_process();
671        for phase in [
672            ProcessPhase::Pending,
673            ProcessPhase::Execing,
674            ProcessPhase::Running,
675            ProcessPhase::Attested,
676            ProcessPhase::Failed,
677        ] {
678            assert_eq!(
679                evaluate_now(&p, phase),
680                AutoTerminate::Skip,
681                "evaluate_now must delegate to evaluate on permanent-process corner (phase={phase:?})",
682            );
683        }
684    }
685
686    #[test]
687    fn evaluate_now_agrees_with_evaluate_on_teardown_policy_corner() {
688        // Peer-parity witness on the teardown-policy corner: for a
689        // Process whose ephemeral teardown policy fires on
690        // `Attested` / `Failed`, both peers must return an
691        // `AutoTerminate::Now` verdict. The reason payload IS allowed
692        // to differ by microseconds (`TtlExpired`'s `elapsed` field
693        // reads a fresh `Utc::now()` inside `evaluate_now`), but the
694        // teardown-policy corner emits `TerminateReason::TeardownPolicy`
695        // whose payload is (policy, phase) — no wall-clock drift.
696        let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
697        for phase in [ProcessPhase::Attested, ProcessPhase::Failed] {
698            let via_now = evaluate_now(&p, phase);
699            let via_evaluate = evaluate(&p, phase, Utc::now());
700            assert_eq!(
701                via_now, via_evaluate,
702                "evaluate_now teardown-policy verdict must match evaluate's on {phase:?}",
703            );
704            assert!(
705                matches!(via_now, AutoTerminate::Now { .. }),
706                "expected AutoTerminate::Now on {phase:?}, got {via_now:?}",
707            );
708        }
709        // Non-terminal phase → both must return Skip (teardown policy
710        // gates on terminal phases only).
711        assert_eq!(
712            evaluate_now(&p, ProcessPhase::Running),
713            AutoTerminate::Skip,
714            "teardown policy must not fire on non-terminal Running phase",
715        );
716    }
717
718    #[test]
719    fn evaluate_now_fires_ttl_when_elapsed() {
720        // Wall-clock-anchored TTL fires when the creation-anchored
721        // TTL has elapsed against the pinned `Utc::now()` read. The
722        // fixture stamps `metadata.creation_timestamp` 60s ago and
723        // sets a 30s TTL — so `evaluate_now` at reconcile time
724        // reads `Utc::now()`, subtracts the 60s-ago anchor, and
725        // returns `Now(TtlExpired{...})` on the VERIFY-phase read.
726        // A regression that pinned the delegated `now` to a stale
727        // constant (e.g. `DateTime::default()`) would silently miss
728        // the elapsed-TTL corner and return `Skip` here — the pin
729        // surfaces that drift at this test rather than as silent
730        // never-terminating ephemeral envs in production.
731        let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
732        assert!(
733            matches!(
734                evaluate_now(&p, ProcessPhase::Running),
735                AutoTerminate::Now { .. }
736            ),
737            "TTL elapsed via wall-clock must fire Now verdict",
738        );
739    }
740
741    #[test]
742    fn evaluate_now_skips_when_ttl_not_yet_elapsed() {
743        // Peer of the elapsed-TTL pin above: a 1h TTL against a
744        // 60s-old creation anchor must NOT fire; the pinned
745        // `Utc::now()` read stays within the TTL window and
746        // `evaluate_now` returns `Skip`. A regression that pinned
747        // the delegated `now` to a far-future constant would trip
748        // the elapsed corner unconditionally and force-terminate
749        // every ephemeral Process before its TTL — the pin
750        // surfaces that drift here.
751        let p = ephemeral_process("1h", TeardownPolicy::Never, 60);
752        assert_eq!(
753            evaluate_now(&p, ProcessPhase::Running),
754            AutoTerminate::Skip,
755            "TTL not yet elapsed via wall-clock must not fire",
756        );
757    }
758
759    /// REASON-STRING CONTRACT: the operator-visible reason composes
760    /// the canonical PascalCase projection of `TeardownPolicy` and
761    /// `ProcessPhase` (via Display) rather than the Debug formatting
762    /// used pre-lift. A future variant rename of either enum updates
763    /// the reason string at ONE site (the `as_str` arm) instead of
764    /// drifting between the typed surface and the operator log.
765    #[test]
766    fn teardown_reason_string_uses_canonical_projection() {
767        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
768        match evaluate(&p, ProcessPhase::Attested, Utc::now()) {
769            AutoTerminate::Now { reason } => {
770                let rendered = reason.to_string();
771                assert!(
772                    rendered.contains("teardown_policy=OnAttested"),
773                    "expected canonical PascalCase policy, got: {rendered}",
774                );
775                assert!(
776                    rendered.contains("fired on Attested"),
777                    "expected canonical PascalCase phase, got: {rendered}",
778                );
779            }
780            other => panic!("expected AutoTerminate::Now, got {other:?}"),
781        }
782
783        let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
784        match evaluate(&p, ProcessPhase::Failed, Utc::now()) {
785            AutoTerminate::Now { reason } => {
786                let rendered = reason.to_string();
787                assert!(rendered.contains("teardown_policy=Always"));
788                assert!(rendered.contains("fired on Failed"));
789            }
790            other => panic!("expected AutoTerminate::Now, got {other:?}"),
791        }
792    }
793
794    // ── TerminateReason / TerminateReasonKind closed-set contracts ────
795
796    /// BYTE-FOR-BYTE PRE-LIFT CONTRACT: the Display impl on
797    /// `TerminateReason` must produce the exact string the pre-lift
798    /// inline `format!(…)` calls produced. Existing alerts, dashboards,
799    /// and operator runbooks that grep `status.message` for these
800    /// substrings keep matching. A future variant rename of
801    /// `TeardownPolicy` / `ProcessPhase` updates the rendered string
802    /// here automatically (Display reads `as_str` projection), but the
803    /// template — `"ephemeral lifetime: teardown_policy={} fired on {}"`
804    /// vs `"ephemeral lifetime: ttl={} expired (elapsed={}s)"` — is
805    /// pinned at the Display site.
806    #[test]
807    fn terminate_reason_display_matches_pre_lift() {
808        // TeardownPolicy variant — every combination of policy × phase
809        // sweeps both PascalCase projections.
810        for policy in TeardownPolicy::ALL {
811            for phase in ProcessPhase::ALL {
812                let reason = TerminateReason::TeardownPolicy { policy, phase };
813                let expected = format!(
814                    "ephemeral lifetime: teardown_policy={} fired on {}",
815                    policy.as_str(),
816                    phase.as_str(),
817                );
818                assert_eq!(
819                    reason.to_string(),
820                    expected,
821                    "Display drifted for ({policy:?}, {phase:?})",
822                );
823            }
824        }
825        // TtlExpired variant — pins the ttl-verbatim + elapsed-secs
826        // template against representative humantime strings the
827        // EphemeralLifetime.ttl field accepts.
828        for (ttl, elapsed_secs) in [("1h", 0u64), ("30m", 60), ("90s", 100), ("5m30s", 3600)] {
829            let reason = TerminateReason::TtlExpired {
830                ttl: ttl.to_string(),
831                elapsed: Duration::from_secs(elapsed_secs),
832            };
833            assert_eq!(
834                reason.to_string(),
835                format!("ephemeral lifetime: ttl={ttl} expired (elapsed={elapsed_secs}s)"),
836            );
837        }
838    }
839
840    /// Reason `kind()` projection — closed-set match so a future
841    /// variant triggers exhaustiveness checking at the projection
842    /// site rather than silently bucketing through a wildcard. Every
843    /// variant's `kind()` matches its `TerminateReasonKind` peer.
844    #[test]
845    fn terminate_reason_kind_truth_table() {
846        assert_eq!(
847            TerminateReason::TeardownPolicy {
848                policy: TeardownPolicy::Always,
849                phase: ProcessPhase::Attested,
850            }
851            .kind(),
852            TerminateReasonKind::TeardownPolicy,
853        );
854        assert_eq!(
855            TerminateReason::TtlExpired {
856                ttl: "1h".to_string(),
857                elapsed: Duration::from_secs(0),
858            }
859            .kind(),
860            TerminateReasonKind::TtlExpired,
861        );
862    }
863
864    /// `ALL` is the source of truth; a variant added without an `ALL`
865    /// entry fails here (uniqueness check) before any sweep test below
866    /// runs. Arity is asserted by the array type itself (`[Self; 2]`).
867    /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
868    /// [`TerminateReasonKind`] — pins the structural three-plus-one
869    /// (`ALL` is non-empty, every variant round-trips through
870    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
871    /// outside the closed set) at ONE call site. Replaces the
872    /// hand-derived `terminate_reason_kind_all_is_unique_and_complete`
873    /// + `terminate_reason_kind_roundtrip_via_as_str` + the empty-input
874    /// arm of `unknown_terminate_reason_kind_errors`. `FromStr`
875    /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
876    /// so this helper exercises the same code path the lifetime-clock
877    /// evaluator hits when parsing a typed reason back out of a
878    /// `status.conditions[].reason` slot.
879    #[test]
880    fn terminate_reason_kind_is_well_formed_closed_set() {
881        tatara_closed_set::assert_closed_set_well_formed::<TerminateReasonKind>();
882    }
883
884    /// `Display` IS `as_str` — pinning this lets future callers reach
885    /// for either projection without drift.
886    #[test]
887    fn terminate_reason_kind_display_matches_as_str() {
888        crate::tagged_union::assert_display_matches_label::<TerminateReasonKind>();
889    }
890
891    /// Every kind's `as_str` is in canonical PascalCase. The first
892    /// character is uppercase; no whitespace; no separators. The
893    /// `tatara-process` PascalCase idiom holds at one test site.
894    #[test]
895    fn terminate_reason_kind_as_str_is_pascal_case() {
896        for kind in TerminateReasonKind::ALL {
897            let s = kind.as_str();
898            assert!(!s.is_empty(), "as_str empty for {kind:?}");
899            assert!(
900                s.chars().next().unwrap().is_ascii_uppercase(),
901                "as_str not PascalCase for {kind:?}: {s}",
902            );
903            assert!(
904                !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
905                "as_str carries separator for {kind:?}: {s}",
906            );
907        }
908    }
909
910    /// `FromStr` rejects strings outside the canonical projection
911    /// (lowercased / typo / cross-axis-leaked) and echoes the input
912    /// verbatim. The empty-string arm is covered by
913    /// `terminate_reason_kind_is_well_formed_closed_set` via the
914    /// [`tatara_lisp::ClosedSet`] contract; the verbatim-echo arms
915    /// stay here because they pin the `UnknownTerminateReasonKind`
916    /// newtype payload contract the trait's `make_unknown` cannot
917    /// see. Cross-axis inputs (ProcessPhase / TeardownPolicy variant
918    /// names) MUST fail — `TerminateReasonKind` is its own axis, not
919    /// a transparent reflection of either.
920    #[test]
921    fn unknown_terminate_reason_kind_errors() {
922        use std::str::FromStr;
923        for bad in [
924            "teardownPolicy",
925            "TEARDOWN_POLICY",
926            "Teardown",
927            "TtlExpire",
928            "ttl_expired",
929            "ttlExpired",
930            // Cross-axis-leaked — must NOT cross axes.
931            "Attested",
932            "Failed",
933            "Always",
934            "OnAttested",
935            "OnFailed",
936            "Never",
937            "Permanent",
938            "Ephemeral",
939        ] {
940            let err = TerminateReasonKind::from_str(bad).unwrap_err();
941            assert_eq!(err.0, bad, "error payload should echo input verbatim");
942        }
943    }
944
945    /// The reason `evaluate` returns under teardown maps to
946    /// `TerminateReasonKind::TeardownPolicy` AND its payload reflects
947    /// the spec's `(teardown_policy, current_phase)` verbatim — the
948    /// typed surface IS the source of truth, not an inline format
949    /// template. A future consumer that wants to group reasons by
950    /// kind in metrics labels reads `reason.kind()`, not a substring
951    /// match.
952    #[test]
953    fn evaluate_typed_reason_carries_teardown_payload() {
954        for (policy, phase) in [
955            (TeardownPolicy::Always, ProcessPhase::Attested),
956            (TeardownPolicy::Always, ProcessPhase::Failed),
957            (TeardownPolicy::OnAttested, ProcessPhase::Attested),
958            (TeardownPolicy::OnFailed, ProcessPhase::Failed),
959        ] {
960            let p = ephemeral_process("1h", policy, 60);
961            match evaluate(&p, phase, Utc::now()) {
962                AutoTerminate::Now { reason } => {
963                    assert_eq!(reason.kind(), TerminateReasonKind::TeardownPolicy);
964                    assert_eq!(
965                        reason,
966                        TerminateReason::TeardownPolicy { policy, phase },
967                        "typed payload drift for ({policy:?}, {phase:?})",
968                    );
969                }
970                other => {
971                    panic!("expected AutoTerminate::Now for ({policy:?}, {phase:?}), got {other:?}",)
972                }
973            }
974        }
975    }
976
977    /// TTL expiry returns a `TtlExpired` reason whose `ttl` field is
978    /// the operator-authored humantime string verbatim (NOT the
979    /// parsed `Duration`'s pretty-print) and whose `elapsed` is the
980    /// wall-clock distance. Pinned here so a future evaluator change
981    /// that re-formats the ttl through `humantime::format_duration`
982    /// would fail.
983    #[test]
984    fn evaluate_typed_reason_carries_ttl_payload() {
985        let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
986        let now = Utc::now();
987        match evaluate(&p, ProcessPhase::Running, now) {
988            AutoTerminate::Now { reason } => {
989                assert_eq!(reason.kind(), TerminateReasonKind::TtlExpired);
990                match reason {
991                    TerminateReason::TtlExpired { ttl, elapsed } => {
992                        assert_eq!(ttl, "30s", "ttl should be verbatim spec string");
993                        assert!(
994                            elapsed >= Duration::from_secs(30),
995                            "elapsed should be at least the ttl",
996                        );
997                    }
998                    other => panic!("expected TtlExpired, got {other:?}"),
999                }
1000            }
1001            other => panic!("expected AutoTerminate::Now, got {other:?}"),
1002        }
1003    }
1004
1005    // ── AutoTerminate / AutoTerminateKind closed-set contracts ────────
1006
1007    /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
1008    /// [`AutoTerminateKind`] — pins the structural three-plus-one
1009    /// (`ALL` is non-empty, every variant round-trips through
1010    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1011    /// outside the closed set) at ONE call site. Replaces the
1012    /// hand-derived uniqueness sweep in
1013    /// `auto_terminate_kind_kind_projection_is_exhaustive_over_all`'s
1014    /// pre-lift form + the `auto_terminate_kind_roundtrip_via_as_str`
1015    /// hand-rolled sweep + the empty-input arm of
1016    /// `unknown_auto_terminate_kind_errors`. `FromStr` delegates to
1017    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
1018    /// helper exercises the same code path the lifetime-clock
1019    /// evaluator hits when parsing a typed kind back out of a
1020    /// `status.conditions[].reason` slot.
1021    #[test]
1022    fn auto_terminate_kind_is_well_formed_closed_set() {
1023        tatara_closed_set::assert_closed_set_well_formed::<AutoTerminateKind>();
1024    }
1025
1026    /// Every entry in `ALL` is reachable through a concrete
1027    /// [`AutoTerminate`] value via [`AutoTerminate::kind`] — the
1028    /// projection is exhaustive across the variant set. Pre-lift this
1029    /// pin was bundled with a uniqueness HashSet sweep that
1030    /// [`auto_terminate_kind_is_well_formed_closed_set`] now covers
1031    /// generically through the [`tatara_lisp::ClosedSet`] contract;
1032    /// post-lift this test keeps only the domain-specific
1033    /// `kind()`-exhaustiveness contract (the (variant-name →
1034    /// payload-stripped kind) binding the [`AutoTerminate`] surface
1035    /// projects through). A future third payload-carrying
1036    /// `AutoTerminate` variant updates this pin AND
1037    /// [`AutoTerminate::kind`]'s exhaustiveness match together,
1038    /// exhaustively checked by the compiler.
1039    #[test]
1040    fn auto_terminate_kind_kind_projection_is_exhaustive_over_all() {
1041        let by_all: std::collections::HashSet<_> = AutoTerminateKind::ALL.iter().copied().collect();
1042        let sample_reason = TerminateReason::TtlExpired {
1043            ttl: "1h".into(),
1044            elapsed: Duration::from_secs(0),
1045        };
1046        let by_concrete: std::collections::HashSet<_> = [
1047            AutoTerminate::Skip.kind(),
1048            AutoTerminate::Now {
1049                reason: sample_reason,
1050            }
1051            .kind(),
1052        ]
1053        .into_iter()
1054        .collect();
1055        assert_eq!(
1056            by_concrete, by_all,
1057            "kind() projection not exhaustive over ALL"
1058        );
1059    }
1060
1061    /// BYTE-EXACT canonical wire-format pin — renaming either of the two
1062    /// canonical strings is a wire-format change that fails this test
1063    /// FIRST so it stays a deliberate change, not a silent rename that
1064    /// drifts existing alerts / dashboards / operator runbooks.
1065    #[test]
1066    fn auto_terminate_kind_canonical_names_pinned() {
1067        assert_eq!(AutoTerminateKind::Skip.as_str(), "Skip");
1068        assert_eq!(AutoTerminateKind::Now.as_str(), "Now");
1069    }
1070
1071    /// Every kind's `as_str` is in canonical PascalCase. The first
1072    /// character is uppercase; no whitespace; no separators. The
1073    /// `tatara-process` PascalCase idiom holds at one test site.
1074    #[test]
1075    fn auto_terminate_kind_as_str_is_pascal_case() {
1076        for kind in AutoTerminateKind::ALL {
1077            let s = kind.as_str();
1078            assert!(!s.is_empty(), "as_str empty for {kind:?}");
1079            assert!(
1080                s.chars().next().unwrap().is_ascii_uppercase(),
1081                "as_str not PascalCase for {kind:?}: {s}",
1082            );
1083            assert!(
1084                !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
1085                "as_str carries separator for {kind:?}: {s}",
1086            );
1087        }
1088    }
1089
1090    /// `Display` IS `as_str` — pinning this lets future callers reach
1091    /// for either projection without drift.
1092    #[test]
1093    fn auto_terminate_kind_display_matches_as_str() {
1094        crate::tagged_union::assert_display_matches_label::<AutoTerminateKind>();
1095    }
1096
1097    /// `FromStr` rejects strings outside the canonical projection
1098    /// (lowercased / typo / cross-axis-leaked) and echoes the input
1099    /// verbatim. The empty-string arm AND the round-trip sweep are
1100    /// covered by `auto_terminate_kind_is_well_formed_closed_set` via
1101    /// the [`tatara_lisp::ClosedSet`] contract; the cases here pin the
1102    /// `UnknownAutoTerminateKind` newtype payload contract the
1103    /// trait's `make_unknown` cannot see. Cross-axis inputs
1104    /// (ProcessPhase / TeardownPolicy / TerminateReasonKind variant
1105    /// names) MUST fail — `AutoTerminateKind` is its own axis, not
1106    /// a transparent reflection of any sibling enum.
1107    #[test]
1108    fn unknown_auto_terminate_kind_errors() {
1109        use std::str::FromStr;
1110        for bad in [
1111            "skip",
1112            "now",
1113            "SKIP",
1114            "NOW",
1115            "S",
1116            "N",
1117            "no-op",
1118            "terminate",
1119            // Cross-axis-leaked — must NOT cross axes.
1120            "Attested",
1121            "Failed",
1122            "TeardownPolicy",
1123            "TtlExpired",
1124            "Always",
1125            "Permanent",
1126            "Ephemeral",
1127        ] {
1128            let err = AutoTerminateKind::from_str(bad).unwrap_err();
1129            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1130        }
1131    }
1132
1133    /// `reason()` projection: `Now { reason }` returns `Some(&reason)`,
1134    /// `Skip` returns `None`. The (variant-name → payload-field)
1135    /// binding lives at ONE site so a future third payload-carrying
1136    /// variant updates every consumer through this method's
1137    /// exhaustiveness check rather than scattering destructures across
1138    /// the call graph.
1139    #[test]
1140    fn auto_terminate_reason_projection() {
1141        assert!(AutoTerminate::Skip.reason().is_none());
1142
1143        let reason = TerminateReason::TtlExpired {
1144            ttl: "1h".into(),
1145            elapsed: Duration::from_secs(0),
1146        };
1147        let now = AutoTerminate::Now {
1148            reason: reason.clone(),
1149        };
1150        assert_eq!(now.reason(), Some(&reason));
1151
1152        let teardown = TerminateReason::TeardownPolicy {
1153            policy: TeardownPolicy::OnAttested,
1154            phase: ProcessPhase::Attested,
1155        };
1156        let now = AutoTerminate::Now {
1157            reason: teardown.clone(),
1158        };
1159        assert_eq!(now.reason(), Some(&teardown));
1160    }
1161
1162    /// `is_now` / `is_skip` are exact complements over the closed set —
1163    /// `is_now ⊕ is_skip = true` for every variant. Locks the predicate
1164    /// pair so a future third variant that's neither Skip nor Now must
1165    /// extend BOTH predicates in lockstep (or this contract fails).
1166    #[test]
1167    fn auto_terminate_predicate_pair_is_exhaustive_complement() {
1168        let reason = TerminateReason::TtlExpired {
1169            ttl: "1h".into(),
1170            elapsed: Duration::from_secs(0),
1171        };
1172        for decision in [
1173            AutoTerminate::Skip,
1174            AutoTerminate::Now {
1175                reason: reason.clone(),
1176            },
1177        ] {
1178            assert_ne!(
1179                decision.is_now(),
1180                decision.is_skip(),
1181                "predicate pair drift for {decision:?}",
1182            );
1183            // The kind projection agrees with each predicate.
1184            assert_eq!(decision.is_now(), decision.kind() == AutoTerminateKind::Now);
1185            assert_eq!(
1186                decision.is_skip(),
1187                decision.kind() == AutoTerminateKind::Skip
1188            );
1189            // `reason()` agrees with `is_now`.
1190            assert_eq!(decision.reason().is_some(), decision.is_now());
1191        }
1192    }
1193
1194    /// The `kind()` projection on the typed result of `evaluate` agrees
1195    /// with the behavioural expectation: ephemeral-on-Attested with an
1196    /// OnAttested policy returns `Now`, permanent never does. Closes
1197    /// the loop between the closed-set view and the live decision so
1198    /// any future kind-keyed metrics label (e.g.
1199    /// `tatara_lifetime_clock_decisions_total{kind="Now"}`) reads the
1200    /// typed projection rather than the inline destructure.
1201    #[test]
1202    fn evaluate_decision_kind_agrees_with_runtime_behaviour() {
1203        let p = permanent_process();
1204        for phase in [
1205            ProcessPhase::Pending,
1206            ProcessPhase::Running,
1207            ProcessPhase::Attested,
1208            ProcessPhase::Failed,
1209        ] {
1210            let decision = evaluate(&p, phase, Utc::now());
1211            assert_eq!(
1212                decision.kind(),
1213                AutoTerminateKind::Skip,
1214                "permanent Process must always Skip; got Now for phase={phase:?}",
1215            );
1216            assert!(decision.reason().is_none());
1217        }
1218
1219        let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
1220        let now = Utc::now();
1221        assert_eq!(
1222            evaluate(&p, ProcessPhase::Attested, now).kind(),
1223            AutoTerminateKind::Now,
1224        );
1225        assert_eq!(
1226            evaluate(&p, ProcessPhase::Running, now).kind(),
1227            AutoTerminateKind::Skip,
1228        );
1229    }
1230
1231    #[test]
1232    fn requeue_picks_min_of_default_and_remaining() {
1233        let p = ephemeral_process("5m", TeardownPolicy::Always, 60);
1234        let now = Utc::now();
1235        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1236        // 5m total - 60s elapsed = 240s remaining; default 30s wins.
1237        assert_eq!(d, Duration::from_secs(30));
1238
1239        let p = ephemeral_process("90s", TeardownPolicy::Always, 80);
1240        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1241        // 90s - 80s = 10s remaining; remaining wins.
1242        assert!(d <= Duration::from_secs(11) && d >= Duration::from_secs(9));
1243
1244        let p = ephemeral_process("90s", TeardownPolicy::Always, 91);
1245        let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1246        // Already past TTL — clamp to 1s, not 0.
1247        assert_eq!(d, Duration::from_secs(1));
1248    }
1249}