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/// Phases past which TTL cannot meaningfully fire — the SIGTERM path
375/// is already in progress.
376fn is_terminal_or_exit(p: ProcessPhase) -> bool {
377 matches!(
378 p,
379 ProcessPhase::Exiting | ProcessPhase::Zombie | ProcessPhase::Reaped
380 )
381}
382
383/// Sleep budget the controller should requeue with for a Process whose
384/// `evaluate()` returned `Skip` — picks the smaller of HEARTBEAT and
385/// TTL-remaining so we don't oversleep past expiry.
386pub fn requeue_with_ttl(process: &Process, now: DateTime<Utc>, default: Duration) -> Duration {
387 // Shared `Process::resolved_ephemeral` projection with
388 // [`evaluate`] — the "give me only the unambiguous ephemeral case"
389 // compound-lift primitive on `impl Process` that composes through
390 // `impl Lifetime`'s `resolved_ephemeral` and closes drift with the
391 // export-Job render arm at ONE substrate site.
392 let Some(e) = process.resolved_ephemeral() else {
393 return default;
394 };
395 // Creation-anchor probe rides through the ONE substrate
396 // `Process::created_at` primitive (sibling to the TTL-expiry gate
397 // in `evaluate` above); the `let-else` short-circuits on the
398 // missing-slot corner to the caller's `default` sleep budget.
399 let Some(creation) = process.created_at() else {
400 return default;
401 };
402 // Shared TTL-parse projection with [`evaluate`] above — the
403 // `humantime::parse_duration(&<eph>.ttl).ok()` chain rides through
404 // the ONE substrate primitive
405 // [`crate::lifetime::EphemeralLifetime::ttl_duration`]. The
406 // `let-else` short-circuits on the parse-failure corner (typo,
407 // unsupported unit, non-humantime literal on the wire) to the
408 // caller's `default` sleep budget — the same "no ttl data → do
409 // not fire the timed decision" interpretation the TTL-expiry
410 // gate in [`evaluate`] gives to the `None` arm.
411 let Some(ttl) = e.ttl_duration() else {
412 return default;
413 };
414 // Sibling to the TTL-expiry gate in [`evaluate`] above: the
415 // `(now, creation) → Option<std::time::Duration>` projection rides
416 // through the ONE substrate primitive [`crate::time::elapsed_since`].
417 // The `let-else` short-circuits on the negative-anchor corner
418 // (clock skew or a creation timestamp stamped past `now`) to the
419 // caller's `default` sleep budget — the same "no elapsed data → do
420 // not fire the timed decision" interpretation every other consumer
421 // gives to the `None` arm.
422 let Some(elapsed) = crate::time::elapsed_since(now, creation) else {
423 return default;
424 };
425 let remaining = ttl.checked_sub(elapsed).unwrap_or(Duration::from_secs(0));
426 // Never sleep less than 1s; never longer than the default heartbeat.
427 let pick = std::cmp::min(default, remaining);
428 std::cmp::max(pick, Duration::from_secs(1))
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::crd::ProcessSpec;
435 use crate::intent::{AplicacaoIntent, Intent};
436 use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
437 use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
438
439 fn ephemeral_process(ttl: &str, teardown: TeardownPolicy, age_secs: i64) -> Process {
440 // Struct-update through the ONE substrate composer
441 // `ProcessSpec::gate_compute_defaults` — pre-lift the nine
442 // other slots were hand-authored inline alongside the `intent`
443 // + `lifetime` overrides; post-lift the substrate owns them.
444 let spec = ProcessSpec {
445 intent: Intent {
446 aplicacao: Some(AplicacaoIntent::chart_only("oci://x", "1")),
447 ..Intent::default()
448 },
449 lifetime: Lifetime {
450 ephemeral: Some(EphemeralLifetime {
451 ttl: ttl.into(),
452 teardown_policy: teardown,
453 max_concurrent: 1,
454 exports: vec![],
455 }),
456 ..Lifetime::default()
457 },
458 ..ProcessSpec::gate_compute_defaults()
459 };
460 let mut p = Process::new("e", spec);
461 p.metadata.namespace = Some("ns".into());
462 // Routes through the ONE substrate primitive `crate::time::
463 // seconds_ago` — one of 21 pre-lift exact-match sites past the
464 // ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold.
465 let creation = crate::time::seconds_ago(age_secs);
466 p.metadata.creation_timestamp = Some(Time(creation));
467 p
468 }
469
470 fn permanent_process() -> Process {
471 // Struct-update through the ONE substrate composer
472 // `ProcessSpec::gate_compute_defaults` — the ten other slots
473 // (identity / classification / boundary / compliance /
474 // depends_on / signals / lifetime / routing / encapsulates /
475 // suspended) ride the substrate; only `intent` is overridden.
476 let spec = ProcessSpec {
477 intent: Intent {
478 aplicacao: Some(AplicacaoIntent::chart_only("oci://x", "1")),
479 ..Intent::default()
480 },
481 ..ProcessSpec::gate_compute_defaults()
482 };
483 Process::new("e", spec)
484 }
485
486 #[test]
487 fn permanent_never_auto_terminates() {
488 let p = permanent_process();
489 for phase in [
490 ProcessPhase::Pending,
491 ProcessPhase::Execing,
492 ProcessPhase::Running,
493 ProcessPhase::Attested,
494 ProcessPhase::Failed,
495 ] {
496 assert_eq!(evaluate(&p, phase, Utc::now()), AutoTerminate::Skip);
497 }
498 }
499
500 #[test]
501 fn always_teardown_fires_on_attested_and_failed() {
502 let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
503 let now = Utc::now();
504 assert!(matches!(
505 evaluate(&p, ProcessPhase::Attested, now),
506 AutoTerminate::Now { .. }
507 ));
508 assert!(matches!(
509 evaluate(&p, ProcessPhase::Failed, now),
510 AutoTerminate::Now { .. }
511 ));
512 assert_eq!(
513 evaluate(&p, ProcessPhase::Running, now),
514 AutoTerminate::Skip
515 );
516 }
517
518 #[test]
519 fn on_attested_only_fires_on_attested() {
520 let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
521 let now = Utc::now();
522 assert!(matches!(
523 evaluate(&p, ProcessPhase::Attested, now),
524 AutoTerminate::Now { .. }
525 ));
526 assert_eq!(evaluate(&p, ProcessPhase::Failed, now), AutoTerminate::Skip);
527 }
528
529 #[test]
530 fn on_failed_only_fires_on_failed() {
531 let p = ephemeral_process("1h", TeardownPolicy::OnFailed, 60);
532 let now = Utc::now();
533 assert_eq!(
534 evaluate(&p, ProcessPhase::Attested, now),
535 AutoTerminate::Skip
536 );
537 assert!(matches!(
538 evaluate(&p, ProcessPhase::Failed, now),
539 AutoTerminate::Now { .. }
540 ));
541 }
542
543 #[test]
544 fn never_skips_phase_terminations_but_still_honors_ttl() {
545 let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
546 let now = Utc::now();
547 // TTL elapsed → TTL fires regardless of policy.
548 assert!(matches!(
549 evaluate(&p, ProcessPhase::Running, now),
550 AutoTerminate::Now { .. }
551 ));
552 // But not on a terminal phase (already exiting).
553 assert_eq!(
554 evaluate(&p, ProcessPhase::Exiting, now),
555 AutoTerminate::Skip
556 );
557 }
558
559 #[test]
560 fn ttl_not_yet_elapsed_is_skip() {
561 let p = ephemeral_process("1h", TeardownPolicy::Never, 60);
562 assert_eq!(
563 evaluate(&p, ProcessPhase::Running, Utc::now()),
564 AutoTerminate::Skip
565 );
566 }
567
568 /// REASON-STRING CONTRACT: the operator-visible reason composes
569 /// the canonical PascalCase projection of `TeardownPolicy` and
570 /// `ProcessPhase` (via Display) rather than the Debug formatting
571 /// used pre-lift. A future variant rename of either enum updates
572 /// the reason string at ONE site (the `as_str` arm) instead of
573 /// drifting between the typed surface and the operator log.
574 #[test]
575 fn teardown_reason_string_uses_canonical_projection() {
576 let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
577 match evaluate(&p, ProcessPhase::Attested, Utc::now()) {
578 AutoTerminate::Now { reason } => {
579 let rendered = reason.to_string();
580 assert!(
581 rendered.contains("teardown_policy=OnAttested"),
582 "expected canonical PascalCase policy, got: {rendered}",
583 );
584 assert!(
585 rendered.contains("fired on Attested"),
586 "expected canonical PascalCase phase, got: {rendered}",
587 );
588 }
589 other => panic!("expected AutoTerminate::Now, got {other:?}"),
590 }
591
592 let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
593 match evaluate(&p, ProcessPhase::Failed, Utc::now()) {
594 AutoTerminate::Now { reason } => {
595 let rendered = reason.to_string();
596 assert!(rendered.contains("teardown_policy=Always"));
597 assert!(rendered.contains("fired on Failed"));
598 }
599 other => panic!("expected AutoTerminate::Now, got {other:?}"),
600 }
601 }
602
603 // ── TerminateReason / TerminateReasonKind closed-set contracts ────
604
605 /// BYTE-FOR-BYTE PRE-LIFT CONTRACT: the Display impl on
606 /// `TerminateReason` must produce the exact string the pre-lift
607 /// inline `format!(…)` calls produced. Existing alerts, dashboards,
608 /// and operator runbooks that grep `status.message` for these
609 /// substrings keep matching. A future variant rename of
610 /// `TeardownPolicy` / `ProcessPhase` updates the rendered string
611 /// here automatically (Display reads `as_str` projection), but the
612 /// template — `"ephemeral lifetime: teardown_policy={} fired on {}"`
613 /// vs `"ephemeral lifetime: ttl={} expired (elapsed={}s)"` — is
614 /// pinned at the Display site.
615 #[test]
616 fn terminate_reason_display_matches_pre_lift() {
617 // TeardownPolicy variant — every combination of policy × phase
618 // sweeps both PascalCase projections.
619 for policy in TeardownPolicy::ALL {
620 for phase in ProcessPhase::ALL {
621 let reason = TerminateReason::TeardownPolicy { policy, phase };
622 let expected = format!(
623 "ephemeral lifetime: teardown_policy={} fired on {}",
624 policy.as_str(),
625 phase.as_str(),
626 );
627 assert_eq!(
628 reason.to_string(),
629 expected,
630 "Display drifted for ({policy:?}, {phase:?})",
631 );
632 }
633 }
634 // TtlExpired variant — pins the ttl-verbatim + elapsed-secs
635 // template against representative humantime strings the
636 // EphemeralLifetime.ttl field accepts.
637 for (ttl, elapsed_secs) in [("1h", 0u64), ("30m", 60), ("90s", 100), ("5m30s", 3600)] {
638 let reason = TerminateReason::TtlExpired {
639 ttl: ttl.to_string(),
640 elapsed: Duration::from_secs(elapsed_secs),
641 };
642 assert_eq!(
643 reason.to_string(),
644 format!("ephemeral lifetime: ttl={ttl} expired (elapsed={elapsed_secs}s)"),
645 );
646 }
647 }
648
649 /// Reason `kind()` projection — closed-set match so a future
650 /// variant triggers exhaustiveness checking at the projection
651 /// site rather than silently bucketing through a wildcard. Every
652 /// variant's `kind()` matches its `TerminateReasonKind` peer.
653 #[test]
654 fn terminate_reason_kind_truth_table() {
655 assert_eq!(
656 TerminateReason::TeardownPolicy {
657 policy: TeardownPolicy::Always,
658 phase: ProcessPhase::Attested,
659 }
660 .kind(),
661 TerminateReasonKind::TeardownPolicy,
662 );
663 assert_eq!(
664 TerminateReason::TtlExpired {
665 ttl: "1h".to_string(),
666 elapsed: Duration::from_secs(0),
667 }
668 .kind(),
669 TerminateReasonKind::TtlExpired,
670 );
671 }
672
673 /// `ALL` is the source of truth; a variant added without an `ALL`
674 /// entry fails here (uniqueness check) before any sweep test below
675 /// runs. Arity is asserted by the array type itself (`[Self; 2]`).
676 /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
677 /// [`TerminateReasonKind`] — pins the structural three-plus-one
678 /// (`ALL` is non-empty, every variant round-trips through
679 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
680 /// outside the closed set) at ONE call site. Replaces the
681 /// hand-derived `terminate_reason_kind_all_is_unique_and_complete`
682 /// + `terminate_reason_kind_roundtrip_via_as_str` + the empty-input
683 /// arm of `unknown_terminate_reason_kind_errors`. `FromStr`
684 /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
685 /// so this helper exercises the same code path the lifetime-clock
686 /// evaluator hits when parsing a typed reason back out of a
687 /// `status.conditions[].reason` slot.
688 #[test]
689 fn terminate_reason_kind_is_well_formed_closed_set() {
690 tatara_closed_set::assert_closed_set_well_formed::<TerminateReasonKind>();
691 }
692
693 /// `Display` IS `as_str` — pinning this lets future callers reach
694 /// for either projection without drift.
695 #[test]
696 fn terminate_reason_kind_display_matches_as_str() {
697 crate::tagged_union::assert_display_matches_label::<TerminateReasonKind>();
698 }
699
700 /// Every kind's `as_str` is in canonical PascalCase. The first
701 /// character is uppercase; no whitespace; no separators. The
702 /// `tatara-process` PascalCase idiom holds at one test site.
703 #[test]
704 fn terminate_reason_kind_as_str_is_pascal_case() {
705 for kind in TerminateReasonKind::ALL {
706 let s = kind.as_str();
707 assert!(!s.is_empty(), "as_str empty for {kind:?}");
708 assert!(
709 s.chars().next().unwrap().is_ascii_uppercase(),
710 "as_str not PascalCase for {kind:?}: {s}",
711 );
712 assert!(
713 !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
714 "as_str carries separator for {kind:?}: {s}",
715 );
716 }
717 }
718
719 /// `FromStr` rejects strings outside the canonical projection
720 /// (lowercased / typo / cross-axis-leaked) and echoes the input
721 /// verbatim. The empty-string arm is covered by
722 /// `terminate_reason_kind_is_well_formed_closed_set` via the
723 /// [`tatara_lisp::ClosedSet`] contract; the verbatim-echo arms
724 /// stay here because they pin the `UnknownTerminateReasonKind`
725 /// newtype payload contract the trait's `make_unknown` cannot
726 /// see. Cross-axis inputs (ProcessPhase / TeardownPolicy variant
727 /// names) MUST fail — `TerminateReasonKind` is its own axis, not
728 /// a transparent reflection of either.
729 #[test]
730 fn unknown_terminate_reason_kind_errors() {
731 use std::str::FromStr;
732 for bad in [
733 "teardownPolicy",
734 "TEARDOWN_POLICY",
735 "Teardown",
736 "TtlExpire",
737 "ttl_expired",
738 "ttlExpired",
739 // Cross-axis-leaked — must NOT cross axes.
740 "Attested",
741 "Failed",
742 "Always",
743 "OnAttested",
744 "OnFailed",
745 "Never",
746 "Permanent",
747 "Ephemeral",
748 ] {
749 let err = TerminateReasonKind::from_str(bad).unwrap_err();
750 assert_eq!(err.0, bad, "error payload should echo input verbatim");
751 }
752 }
753
754 /// The reason `evaluate` returns under teardown maps to
755 /// `TerminateReasonKind::TeardownPolicy` AND its payload reflects
756 /// the spec's `(teardown_policy, current_phase)` verbatim — the
757 /// typed surface IS the source of truth, not an inline format
758 /// template. A future consumer that wants to group reasons by
759 /// kind in metrics labels reads `reason.kind()`, not a substring
760 /// match.
761 #[test]
762 fn evaluate_typed_reason_carries_teardown_payload() {
763 for (policy, phase) in [
764 (TeardownPolicy::Always, ProcessPhase::Attested),
765 (TeardownPolicy::Always, ProcessPhase::Failed),
766 (TeardownPolicy::OnAttested, ProcessPhase::Attested),
767 (TeardownPolicy::OnFailed, ProcessPhase::Failed),
768 ] {
769 let p = ephemeral_process("1h", policy, 60);
770 match evaluate(&p, phase, Utc::now()) {
771 AutoTerminate::Now { reason } => {
772 assert_eq!(reason.kind(), TerminateReasonKind::TeardownPolicy);
773 assert_eq!(
774 reason,
775 TerminateReason::TeardownPolicy { policy, phase },
776 "typed payload drift for ({policy:?}, {phase:?})",
777 );
778 }
779 other => {
780 panic!("expected AutoTerminate::Now for ({policy:?}, {phase:?}), got {other:?}",)
781 }
782 }
783 }
784 }
785
786 /// TTL expiry returns a `TtlExpired` reason whose `ttl` field is
787 /// the operator-authored humantime string verbatim (NOT the
788 /// parsed `Duration`'s pretty-print) and whose `elapsed` is the
789 /// wall-clock distance. Pinned here so a future evaluator change
790 /// that re-formats the ttl through `humantime::format_duration`
791 /// would fail.
792 #[test]
793 fn evaluate_typed_reason_carries_ttl_payload() {
794 let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
795 let now = Utc::now();
796 match evaluate(&p, ProcessPhase::Running, now) {
797 AutoTerminate::Now { reason } => {
798 assert_eq!(reason.kind(), TerminateReasonKind::TtlExpired);
799 match reason {
800 TerminateReason::TtlExpired { ttl, elapsed } => {
801 assert_eq!(ttl, "30s", "ttl should be verbatim spec string");
802 assert!(
803 elapsed >= Duration::from_secs(30),
804 "elapsed should be at least the ttl",
805 );
806 }
807 other => panic!("expected TtlExpired, got {other:?}"),
808 }
809 }
810 other => panic!("expected AutoTerminate::Now, got {other:?}"),
811 }
812 }
813
814 // ── AutoTerminate / AutoTerminateKind closed-set contracts ────────
815
816 /// Exercise the substrate-wide [`tatara_lisp::ClosedSet`] contract on
817 /// [`AutoTerminateKind`] — pins the structural three-plus-one
818 /// (`ALL` is non-empty, every variant round-trips through
819 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
820 /// outside the closed set) at ONE call site. Replaces the
821 /// hand-derived uniqueness sweep in
822 /// `auto_terminate_kind_kind_projection_is_exhaustive_over_all`'s
823 /// pre-lift form + the `auto_terminate_kind_roundtrip_via_as_str`
824 /// hand-rolled sweep + the empty-input arm of
825 /// `unknown_auto_terminate_kind_errors`. `FromStr` delegates to
826 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
827 /// helper exercises the same code path the lifetime-clock
828 /// evaluator hits when parsing a typed kind back out of a
829 /// `status.conditions[].reason` slot.
830 #[test]
831 fn auto_terminate_kind_is_well_formed_closed_set() {
832 tatara_closed_set::assert_closed_set_well_formed::<AutoTerminateKind>();
833 }
834
835 /// Every entry in `ALL` is reachable through a concrete
836 /// [`AutoTerminate`] value via [`AutoTerminate::kind`] — the
837 /// projection is exhaustive across the variant set. Pre-lift this
838 /// pin was bundled with a uniqueness HashSet sweep that
839 /// [`auto_terminate_kind_is_well_formed_closed_set`] now covers
840 /// generically through the [`tatara_lisp::ClosedSet`] contract;
841 /// post-lift this test keeps only the domain-specific
842 /// `kind()`-exhaustiveness contract (the (variant-name →
843 /// payload-stripped kind) binding the [`AutoTerminate`] surface
844 /// projects through). A future third payload-carrying
845 /// `AutoTerminate` variant updates this pin AND
846 /// [`AutoTerminate::kind`]'s exhaustiveness match together,
847 /// exhaustively checked by the compiler.
848 #[test]
849 fn auto_terminate_kind_kind_projection_is_exhaustive_over_all() {
850 let by_all: std::collections::HashSet<_> = AutoTerminateKind::ALL.iter().copied().collect();
851 let sample_reason = TerminateReason::TtlExpired {
852 ttl: "1h".into(),
853 elapsed: Duration::from_secs(0),
854 };
855 let by_concrete: std::collections::HashSet<_> = [
856 AutoTerminate::Skip.kind(),
857 AutoTerminate::Now {
858 reason: sample_reason,
859 }
860 .kind(),
861 ]
862 .into_iter()
863 .collect();
864 assert_eq!(
865 by_concrete, by_all,
866 "kind() projection not exhaustive over ALL"
867 );
868 }
869
870 /// BYTE-EXACT canonical wire-format pin — renaming either of the two
871 /// canonical strings is a wire-format change that fails this test
872 /// FIRST so it stays a deliberate change, not a silent rename that
873 /// drifts existing alerts / dashboards / operator runbooks.
874 #[test]
875 fn auto_terminate_kind_canonical_names_pinned() {
876 assert_eq!(AutoTerminateKind::Skip.as_str(), "Skip");
877 assert_eq!(AutoTerminateKind::Now.as_str(), "Now");
878 }
879
880 /// Every kind's `as_str` is in canonical PascalCase. The first
881 /// character is uppercase; no whitespace; no separators. The
882 /// `tatara-process` PascalCase idiom holds at one test site.
883 #[test]
884 fn auto_terminate_kind_as_str_is_pascal_case() {
885 for kind in AutoTerminateKind::ALL {
886 let s = kind.as_str();
887 assert!(!s.is_empty(), "as_str empty for {kind:?}");
888 assert!(
889 s.chars().next().unwrap().is_ascii_uppercase(),
890 "as_str not PascalCase for {kind:?}: {s}",
891 );
892 assert!(
893 !s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
894 "as_str carries separator for {kind:?}: {s}",
895 );
896 }
897 }
898
899 /// `Display` IS `as_str` — pinning this lets future callers reach
900 /// for either projection without drift.
901 #[test]
902 fn auto_terminate_kind_display_matches_as_str() {
903 crate::tagged_union::assert_display_matches_label::<AutoTerminateKind>();
904 }
905
906 /// `FromStr` rejects strings outside the canonical projection
907 /// (lowercased / typo / cross-axis-leaked) and echoes the input
908 /// verbatim. The empty-string arm AND the round-trip sweep are
909 /// covered by `auto_terminate_kind_is_well_formed_closed_set` via
910 /// the [`tatara_lisp::ClosedSet`] contract; the cases here pin the
911 /// `UnknownAutoTerminateKind` newtype payload contract the
912 /// trait's `make_unknown` cannot see. Cross-axis inputs
913 /// (ProcessPhase / TeardownPolicy / TerminateReasonKind variant
914 /// names) MUST fail — `AutoTerminateKind` is its own axis, not
915 /// a transparent reflection of any sibling enum.
916 #[test]
917 fn unknown_auto_terminate_kind_errors() {
918 use std::str::FromStr;
919 for bad in [
920 "skip",
921 "now",
922 "SKIP",
923 "NOW",
924 "S",
925 "N",
926 "no-op",
927 "terminate",
928 // Cross-axis-leaked — must NOT cross axes.
929 "Attested",
930 "Failed",
931 "TeardownPolicy",
932 "TtlExpired",
933 "Always",
934 "Permanent",
935 "Ephemeral",
936 ] {
937 let err = AutoTerminateKind::from_str(bad).unwrap_err();
938 assert_eq!(err.0, bad, "error payload should echo input verbatim");
939 }
940 }
941
942 /// `reason()` projection: `Now { reason }` returns `Some(&reason)`,
943 /// `Skip` returns `None`. The (variant-name → payload-field)
944 /// binding lives at ONE site so a future third payload-carrying
945 /// variant updates every consumer through this method's
946 /// exhaustiveness check rather than scattering destructures across
947 /// the call graph.
948 #[test]
949 fn auto_terminate_reason_projection() {
950 assert!(AutoTerminate::Skip.reason().is_none());
951
952 let reason = TerminateReason::TtlExpired {
953 ttl: "1h".into(),
954 elapsed: Duration::from_secs(0),
955 };
956 let now = AutoTerminate::Now {
957 reason: reason.clone(),
958 };
959 assert_eq!(now.reason(), Some(&reason));
960
961 let teardown = TerminateReason::TeardownPolicy {
962 policy: TeardownPolicy::OnAttested,
963 phase: ProcessPhase::Attested,
964 };
965 let now = AutoTerminate::Now {
966 reason: teardown.clone(),
967 };
968 assert_eq!(now.reason(), Some(&teardown));
969 }
970
971 /// `is_now` / `is_skip` are exact complements over the closed set —
972 /// `is_now ⊕ is_skip = true` for every variant. Locks the predicate
973 /// pair so a future third variant that's neither Skip nor Now must
974 /// extend BOTH predicates in lockstep (or this contract fails).
975 #[test]
976 fn auto_terminate_predicate_pair_is_exhaustive_complement() {
977 let reason = TerminateReason::TtlExpired {
978 ttl: "1h".into(),
979 elapsed: Duration::from_secs(0),
980 };
981 for decision in [
982 AutoTerminate::Skip,
983 AutoTerminate::Now {
984 reason: reason.clone(),
985 },
986 ] {
987 assert_ne!(
988 decision.is_now(),
989 decision.is_skip(),
990 "predicate pair drift for {decision:?}",
991 );
992 // The kind projection agrees with each predicate.
993 assert_eq!(decision.is_now(), decision.kind() == AutoTerminateKind::Now);
994 assert_eq!(
995 decision.is_skip(),
996 decision.kind() == AutoTerminateKind::Skip
997 );
998 // `reason()` agrees with `is_now`.
999 assert_eq!(decision.reason().is_some(), decision.is_now());
1000 }
1001 }
1002
1003 /// The `kind()` projection on the typed result of `evaluate` agrees
1004 /// with the behavioural expectation: ephemeral-on-Attested with an
1005 /// OnAttested policy returns `Now`, permanent never does. Closes
1006 /// the loop between the closed-set view and the live decision so
1007 /// any future kind-keyed metrics label (e.g.
1008 /// `tatara_lifetime_clock_decisions_total{kind="Now"}`) reads the
1009 /// typed projection rather than the inline destructure.
1010 #[test]
1011 fn evaluate_decision_kind_agrees_with_runtime_behaviour() {
1012 let p = permanent_process();
1013 for phase in [
1014 ProcessPhase::Pending,
1015 ProcessPhase::Running,
1016 ProcessPhase::Attested,
1017 ProcessPhase::Failed,
1018 ] {
1019 let decision = evaluate(&p, phase, Utc::now());
1020 assert_eq!(
1021 decision.kind(),
1022 AutoTerminateKind::Skip,
1023 "permanent Process must always Skip; got Now for phase={phase:?}",
1024 );
1025 assert!(decision.reason().is_none());
1026 }
1027
1028 let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
1029 let now = Utc::now();
1030 assert_eq!(
1031 evaluate(&p, ProcessPhase::Attested, now).kind(),
1032 AutoTerminateKind::Now,
1033 );
1034 assert_eq!(
1035 evaluate(&p, ProcessPhase::Running, now).kind(),
1036 AutoTerminateKind::Skip,
1037 );
1038 }
1039
1040 #[test]
1041 fn requeue_picks_min_of_default_and_remaining() {
1042 let p = ephemeral_process("5m", TeardownPolicy::Always, 60);
1043 let now = Utc::now();
1044 let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1045 // 5m total - 60s elapsed = 240s remaining; default 30s wins.
1046 assert_eq!(d, Duration::from_secs(30));
1047
1048 let p = ephemeral_process("90s", TeardownPolicy::Always, 80);
1049 let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1050 // 90s - 80s = 10s remaining; remaining wins.
1051 assert!(d <= Duration::from_secs(11) && d >= Duration::from_secs(9));
1052
1053 let p = ephemeral_process("90s", TeardownPolicy::Always, 91);
1054 let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
1055 // Already past TTL — clamp to 1s, not 0.
1056 assert_eq!(d, Duration::from_secs(1));
1057 }
1058}