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