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