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 /// Permanent-only [`Lifetime`] — the `permanent` slot is populated with
155 /// the zero-sized [`PermanentLifetime`] marker and the `ephemeral` slot
156 /// is `None`. Peer of [`Self::ephemeral`] on the `LifetimeKind` closed
157 /// set; the two composers between them cover every non-ambiguous
158 /// non-empty corner of the two-slot tagged-union wire shape.
159 ///
160 /// Pre-lift the 4-token `Lifetime { permanent: Some(PermanentLifetime
161 /// {}), ephemeral: None }` (equivalently `Lifetime { permanent:
162 /// Some(PermanentLifetime {}), ..Lifetime::default() }`) fixture
163 /// literal was hand-authored at FOUR workspace-wide sites past the
164 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
165 ///
166 /// * [`crate::crd::tests::permanent_only_process`] — `Process` fixture
167 /// with the permanent-only lifetime slot for the `resolved_ephemeral`
168 /// projection test matrix.
169 /// * [`tests::resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot`]
170 /// — the "Permanent-only" branch of the same matrix in this module.
171 /// * [`tests::single_slot_lifetime`] — closed-set helper's `Permanent`
172 /// arm, shared across property tests that walk `LifetimeKind::ALL`.
173 /// * `tatara-pool-reconciler::controller_pool::process_from_template`
174 /// — production seed of a pool-member Process's lifetime slot on the
175 /// fork path (Pool member starts Permanent; allocation flips it).
176 ///
177 /// Post-lift every callsite reads `Lifetime::permanent()` and the
178 /// substrate owns the shape. A future normalization (a warn-log on
179 /// callers that construct a permanent-only lifetime past a hardening
180 /// window, an audit trail that stamps a compile-generation onto the
181 /// zero-sized `PermanentLifetime`, or a deprecation of the empty
182 /// marker in favor of a richer permanent variant) lands at THIS ONE
183 /// substrate function and every downstream consumer inherits the
184 /// upgrade mechanically.
185 ///
186 /// The ambiguous corner (both `permanent` AND `ephemeral` set) is
187 /// deliberately NOT reachable through this composer — the composer's
188 /// contract is "the resolver picks `Permanent`", and an ambiguous
189 /// `Lifetime` resolves to [`LifetimeError::Ambiguous`], not to
190 /// `Permanent`. Tests that exercise the ambiguous corner (e.g.
191 /// [`crate::crd::tests::ambiguous_lifetime_process`],
192 /// [`tests::ambiguous_lifetime_errors`]) stay hand-authored as
193 /// struct literals — they need to violate the "exactly one slot"
194 /// invariant this composer preserves.
195 ///
196 /// Sibling composer: [`Self::ephemeral`] on the `Ephemeral` arm of
197 /// the same `LifetimeKind` closed set.
198 ///
199 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
200 /// the `Lifetime { permanent: Some(PermanentLifetime {}), .. }`
201 /// shape recurred at four hand-authored sites past the ★★
202 /// PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE
203 /// owner here). THEORY.md §II.1 invariant 5 (composition preserves
204 /// proofs — the pins bind the resolved-variant corner AND the
205 /// discriminator kind AND the round-trip through [`Self::variant`]
206 /// so a regression that drifted any surface fails at
207 /// `tests::permanent_composer_*` here rather than as silent
208 /// operator-facing skew between the fork-path production seed and
209 /// the test-side fixture literals).
210 #[must_use]
211 pub fn permanent() -> Self {
212 Self {
213 permanent: Some(PermanentLifetime {}),
214 ephemeral: None,
215 }
216 }
217
218 /// Ephemeral-only [`Lifetime`] — the `ephemeral` slot is populated
219 /// with the supplied [`EphemeralLifetime`] and the `permanent` slot
220 /// is `None`. Peer of [`Self::permanent`] on the `LifetimeKind`
221 /// closed set.
222 ///
223 /// Pre-lift the 4-token `Lifetime { permanent: None, ephemeral:
224 /// Some(<e>) }` (equivalently `Lifetime { ephemeral: Some(<e>),
225 /// ..Lifetime::default() }`) fixture literal was hand-authored at
226 /// ELEVEN+ workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
227 /// duplication threshold, split across production seeds
228 /// (`tatara-process::ephemeral::From<EphemeralSpec> for ProcessSpec`
229 /// — the `(defephemeral …)` Lisp form's typed lowering;
230 /// `tatara-pool-reconciler::controller_allocation` — the allocator
231 /// Bind arm flipping a pool-member Process from Permanent to
232 /// Ephemeral with the requestor's TTL) and test fixtures across
233 /// [`crate::crd`], [`crate::lifetime_clock`], this module,
234 /// `tatara-pool-reconciler`, and `tatara-reconciler::render`.
235 ///
236 /// Post-lift every callsite reads `Lifetime::ephemeral(<e>)` and
237 /// the substrate owns the shape. A future normalization (a
238 /// per-fleet TTL floor stamp before storing the inner
239 /// `EphemeralLifetime`, an audit trail that records the composed
240 /// lifetime's provenance, a shared warn on empty `exports` at
241 /// Attested-terminal phases) lands at THIS ONE substrate function
242 /// and every downstream consumer inherits the upgrade mechanically.
243 ///
244 /// Return-form axis: takes an owned [`EphemeralLifetime`] rather
245 /// than a `&EphemeralLifetime`, matching every current caller
246 /// (each constructs the inner `EphemeralLifetime` directly at the
247 /// call site as a rvalue-shape struct literal, then hands it to
248 /// the composer).
249 ///
250 /// The ambiguous corner (both slots set) is NOT reachable through
251 /// this composer — see [`Self::permanent`]'s docs for the parity
252 /// with the peer + the rationale for keeping ambiguous-corner
253 /// tests as hand-authored struct literals.
254 ///
255 /// Sibling composer: [`Self::permanent`] on the `Permanent` arm of
256 /// the same `LifetimeKind` closed set.
257 ///
258 /// Theory anchor: same as [`Self::permanent`] — THEORY.md §VI.1
259 /// (generation over composition) + §II.1 invariant 5 (composition
260 /// preserves proofs).
261 #[must_use]
262 pub fn ephemeral(e: EphemeralLifetime) -> Self {
263 Self {
264 permanent: None,
265 ephemeral: Some(e),
266 }
267 }
268
269 /// Resolve to a variant view. Empty resolves to `Permanent` (a static
270 /// borrow on the embedded `DEFAULT_PERMANENT`); ambiguous (both set) is
271 /// an error.
272 ///
273 /// Sweeps over `LifetimeKind::ALL` so a third variant added with an
274 /// `ALL` entry is structurally honored at this site — no parallel
275 /// `is_some()` count, no per-variant if-let chain.
276 pub fn variant(&self) -> Result<LifetimeVariant<'_>, LifetimeError> {
277 use crate::tagged_union::{resolve, ResolveError};
278 match resolve(LifetimeKind::ALL.into_iter().map(|k| k.select(self))) {
279 Ok(v) => Ok(v),
280 Err(ResolveError::None) => Ok(LifetimeVariant::Permanent(&DEFAULT_PERMANENT)),
281 Err(ResolveError::Many) => Err(LifetimeError::Ambiguous),
282 }
283 }
284
285 /// True iff `ephemeral` is set.
286 pub fn is_ephemeral(&self) -> bool {
287 self.ephemeral.is_some()
288 }
289
290 /// Compound projection: `Some(&e)` iff [`Self::variant`] resolves
291 /// unambiguously to `Ephemeral(e)`; `None` for every other outcome
292 /// (empty → `Permanent` default, `Permanent` slot only, or
293 /// [`LifetimeError::Ambiguous`] when BOTH slots are set).
294 ///
295 /// The ambiguous case is deliberately collapsed to `None`: an
296 /// operator-authored spec with both `permanent:` and `ephemeral:`
297 /// populated is a mis-configuration, and every production consumer
298 /// of the pair [`crate::lifetime_clock::evaluate`] +
299 /// [`crate::lifetime_clock::requeue_with_ttl`] previously
300 /// hand-rolled the SAME two-step projection
301 /// (`variant().ok()?.as_ephemeral()`) whose Err-arm and
302 /// Permanent-arm both fell through to the same "no ephemeral
303 /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
304 /// Lifting that chained collapse to ONE substrate primitive puts
305 /// "the ephemeral spec now, iff the resolver picked it" behind a
306 /// single call site and closes the possibility of a per-consumer
307 /// drift where one branch honors ambiguity and the other doesn't.
308 ///
309 /// A future third variant added to `Lifetime` (e.g. `Burst` for
310 /// budget-capped non-TTL lifetimes) reaches this projection
311 /// through the SAME [`Self::variant`] resolver + the SAME
312 /// [`LifetimeVariant::as_ephemeral`] discriminator, so the
313 /// ephemeral-only projection stays intact without a new arm here.
314 ///
315 /// Pinned by
316 /// `resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot`.
317 pub fn resolved_ephemeral(&self) -> Option<&EphemeralLifetime> {
318 // Pattern-match on the owned `LifetimeVariant` (not
319 // `variant.as_ephemeral()`) so the returned borrow carries the
320 // resolver's `'_self` lifetime through directly instead of the
321 // shorter borrow `as_ephemeral(&self)` synthesizes on the
322 // temporary variant. Symmetric peer discriminator arm
323 // `LifetimeVariant::as_ephemeral` still owns the closed-set
324 // projection for consumers that hold the variant by borrow;
325 // this projection is the compound-lift entry point for
326 // consumers whose call graph starts from `&Lifetime`.
327 match self.variant().ok()? {
328 LifetimeVariant::Ephemeral(e) => Some(e),
329 LifetimeVariant::Permanent(_) => None,
330 }
331 }
332}
333
334const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
335
336/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
337/// SIGTERM terminates only on explicit operator action.
338#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
339#[serde(rename_all = "camelCase")]
340pub struct PermanentLifetime {}
341
342/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
343///
344/// Phase semantics:
345/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
346/// reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
347/// - On `Failed` with `teardown_policy ∈ {OnFailed, Always}`:
348/// same. Otherwise Process stays at Failed for forensic inspection.
349/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
350/// reconcile loop tick. TTL expiry while in any non-terminal phase
351/// forces SIGTERM regardless of `teardown_policy`.
352#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
353#[serde(rename_all = "camelCase")]
354pub struct EphemeralLifetime {
355 /// `humantime`-parseable duration from `phaseSince(Forking)` after
356 /// which the Process is force-SIGTERM'd.
357 #[serde(default = "default_ttl")]
358 pub ttl: String,
359
360 /// When the Process auto-terminates.
361 #[serde(default)]
362 pub teardown_policy: TeardownPolicy,
363
364 /// Cluster-wide concurrency budget across ephemeral Processes that
365 /// share the same `spec.identity.name_override` / chart_ref.
366 /// `0` = no cap. Enforced by the reconciler before transitioning out
367 /// of `Pending`.
368 #[serde(default = "default_max_concurrent")]
369 pub max_concurrent: u32,
370
371 /// Declared exports — what artifacts survive teardown and where
372 /// they flow. Empty (default) = nothing survives, matching the
373 /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
374 /// independently triggered during the reconciler's `Releasing`
375 /// phase against the terminal `ProcessPhase` reached.
376 ///
377 /// See [`crate::export`] for the full type. All exports flow
378 /// through the pleme-io Vector + NATS layer — there is no
379 /// per-spec ad-hoc sink.
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub exports: Vec<ExportSpec>,
382}
383
384impl EphemeralLifetime {
385 /// The [`humantime`]-parsed `self.ttl` duration, or `None` if the
386 /// operator-authored `ttl` string doesn't parse — the one-line
387 /// collapse of the `humantime::parse_duration(&<eph>.ttl).ok()`
388 /// chain lifted to ONE typed owner past the ★★ PRIME-DIRECTIVE
389 /// ≥ 2 duplication threshold.
390 ///
391 /// Pre-lift the SAME chain was hand-authored at TWO workspace-wide
392 /// consumer sites in [`crate::lifetime_clock`], both walking
393 /// `humantime::parse_duration(&<ephemeral>.ttl)` on an
394 /// `&EphemeralLifetime` and discarding the parse-error arm to the
395 /// downstream "skip the timed decision" branch:
396 ///
397 /// * [`crate::lifetime_clock::evaluate`] — the TTL-expiry gate.
398 /// Reads `if let Ok(ttl) = humantime::parse_duration(&ephemeral
399 /// .ttl) { … }` inside the non-terminal-phase guard, comparing
400 /// the parsed `Duration` against the wall-clock elapsed distance
401 /// from `metadata.creation_timestamp` to fire
402 /// `AutoTerminate::Now { TtlExpired }`.
403 /// * [`crate::lifetime_clock::requeue_with_ttl`] — the sleep-
404 /// budget picker for the reconciler's next requeue. Reads
405 /// `let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
406 /// return default; };` and short-circuits to the caller's
407 /// `default` sleep budget on parse failure.
408 ///
409 /// Both sites walked the SAME `humantime::parse_duration(&<eph>
410 /// .ttl)` chain and both wanted the Option-shape (the `Ok` arm as
411 /// the parsed `Duration`, the `Err` arm collapsed to the
412 /// downstream skip-branch). Post-lift each caller reaches for
413 /// `<eph>.ttl_duration()` and applies its own tail at its own
414 /// site (`if let Some(ttl) = …` for the guard, `let Some(ttl) =
415 /// … else { return default; }` for the sleep-budget picker).
416 ///
417 /// Return-form axis: `Option<std::time::Duration>` matches the
418 /// downstream comparator's type. The peer projection
419 /// [`crate::time::elapsed_since`] returns the SAME
420 /// `Option<std::time::Duration>` shape, so the TTL-expiry gate's
421 /// `elapsed >= ttl` comparator and the sleep-budget picker's
422 /// `ttl.checked_sub(elapsed)` subtraction each land with both
423 /// operands on the same axis, no per-consumer conversion.
424 ///
425 /// The `None` arm is the "operator's ttl string doesn't parse"
426 /// corner — a typo (`"1our"`), an unsupported unit, a
427 /// non-humantime literal that reached the field. Every consumer
428 /// interprets the corner as "no ttl data → don't fire the timed
429 /// decision" — [`crate::lifetime_clock::evaluate`] skips the
430 /// `AutoTerminate::Now` branch, [`crate::lifetime_clock::
431 /// requeue_with_ttl`] returns the caller's `default` sleep
432 /// budget. The pins below bind that shape.
433 ///
434 /// A future normalization (a per-fleet minimum TTL floor before
435 /// the humantime cast, a canonical unit-normalization pass, a
436 /// warn-log on unparseable strings) lands at THIS ONE substrate
437 /// primitive and every downstream ephemeral-TTL consumer inherits
438 /// the upgrade mechanically — no per-site edit at either of the
439 /// TWO listed callers or at future consumers (an allocation-TTL
440 /// remaining-budget picker, a pool free-TTL floor gate, a
441 /// stable-name claim-arbiter max-age tie-break).
442 ///
443 /// Sibling substrate primitive on the same
444 /// `(humantime string × Option<Duration>) → Option<Duration>`
445 /// axis: [`crate::time::elapsed_since`] — the `(now, anchor) →
446 /// Option<Duration>` peer that every timed-decision gate
447 /// composes with THIS primitive to produce an `elapsed >= ttl` /
448 /// `ttl.checked_sub(elapsed)` comparison.
449 ///
450 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
451 /// the `humantime::parse_duration(&<eph>.ttl).ok()` chain recurred
452 /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
453 /// duplication trigger, and is lifted to ONE owner here).
454 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
455 /// the pins bind the parse-failure corner AND the empty-ttl corner
456 /// AND the humantime edge shapes AND the return-form parity with
457 /// [`crate::time::elapsed_since`], so a regression that drifts any
458 /// surface fails at `tests::ttl_duration_*` here rather than as
459 /// silent operator-facing skew between the TTL-expiry gate and
460 /// the sleep-budget picker on the SAME EphemeralLifetime).
461 #[must_use]
462 pub fn ttl_duration(&self) -> Option<std::time::Duration> {
463 humantime::parse_duration(&self.ttl).ok()
464 }
465
466 /// True iff any declared export's [`crate::export::ExportTrigger`]
467 /// fires for the given terminal-reached phase. The reconciler
468 /// uses this to decide whether to route `Attested`/`Failed`
469 /// through `Releasing` (the export window) or skip straight to
470 /// `Exiting`/`Zombie`.
471 ///
472 /// Returns `false` when the export list is empty or no trigger
473 /// matches — both cases collapse to the existing teardown path.
474 pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
475 self.exports.iter().any(|e| e.when.fires_on(phase))
476 }
477
478 /// Iterate over the exports whose trigger fires on `phase`.
479 /// The reconciler's `handle_releasing` consumes this to emit
480 /// one tatara-export-worker Job per surviving spec.
481 pub fn applicable_exports(
482 &self,
483 phase: ProcessPhase,
484 ) -> impl Iterator<Item = &ExportSpec> + '_ {
485 self.exports.iter().filter(move |e| e.when.fires_on(phase))
486 }
487}
488
489impl Default for EphemeralLifetime {
490 fn default() -> Self {
491 Self {
492 ttl: default_ttl(),
493 teardown_policy: TeardownPolicy::default(),
494 max_concurrent: default_max_concurrent(),
495 exports: Vec::new(),
496 }
497 }
498}
499
500fn default_ttl() -> String {
501 "1h".to_string()
502}
503fn default_max_concurrent() -> u32 {
504 1
505}
506
507/// When an ephemeral Process self-terminates.
508///
509/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
510/// foreign success/failure language — typed phases are the source of truth.
511#[derive(
512 Clone,
513 Copy,
514 Debug,
515 PartialEq,
516 Eq,
517 Hash,
518 Serialize,
519 Deserialize,
520 JsonSchema,
521 Default,
522 tatara_closed_set::DeriveClosedSet,
523)]
524#[serde(rename_all = "PascalCase")]
525#[closed_set(via = "as_str", display, generate_unknown)]
526pub enum TeardownPolicy {
527 /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
528 #[default]
529 Always,
530 /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
531 OnAttested,
532 /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
533 /// TTL or explicit operator SIGTERM.
534 OnFailed,
535 /// Never auto-terminate (TTL still applies).
536 Never,
537}
538
539impl TeardownPolicy {
540 /// The closed set of teardown policies — single source of truth that
541 /// drives the `as_str` / Display / `FromStr` triad and the typed
542 /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
543 /// variant lands at one `ALL` entry + one `as_str` arm + one
544 /// `should_teardown_on` arm — exhaustively checked by the compiler
545 /// (the `[Self; 4]` array literal forces the arity).
546 ///
547 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
548 /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
549 /// [`crate::boundary::ConditionKind::ALL`],
550 /// [`crate::phase::ProcessPhase::ALL`],
551 /// [`crate::signal::ProcessSignal::ALL`].
552 pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
553
554 /// Canonical PascalCase wire-format projection — matches the serde
555 /// `rename_all = "PascalCase"` output verbatim. Used by Display
556 /// (single source of truth), by `FromStr` to identify the variant
557 /// from its annotation / status-field representation, and by
558 /// operator-facing reason strings the reconciler stamps without
559 /// reaching for `{:?}` Debug formatting. Pinned by
560 /// `teardown_policy_as_str_matches_serde`.
561 pub const fn as_str(self) -> &'static str {
562 match self {
563 Self::Always => "Always",
564 Self::OnAttested => "OnAttested",
565 Self::OnFailed => "OnFailed",
566 Self::Never => "Never",
567 }
568 }
569
570 /// True iff, given a `ProcessPhase`, this policy says "tear down."
571 /// ONE typed dispatch over the typed phase enum that replaces the
572 /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
573 /// predicates `lifetime_clock::evaluate` previously branched on.
574 /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
575 /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
576 /// always return `false` — teardown is a terminal-phase decision.
577 ///
578 /// The legacy [`Self::should_teardown_on_attested`] /
579 /// [`Self::should_teardown_on_failed`] predicates remain as thin
580 /// delegates so existing call sites keep their narrow signatures;
581 /// the truth table is pinned by
582 /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
583 pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
584 match phase {
585 ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
586 ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
587 ProcessPhase::Pending
588 | ProcessPhase::Forking
589 | ProcessPhase::Execing
590 | ProcessPhase::Running
591 | ProcessPhase::Reconverging
592 | ProcessPhase::Releasing
593 | ProcessPhase::Exiting
594 | ProcessPhase::Zombie
595 | ProcessPhase::Reaped => false,
596 }
597 }
598
599 /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
600 /// case — kept so existing call sites (notably the truth-table
601 /// test in this module) keep their narrow signature without
602 /// reaching for the typed-phase variant.
603 pub const fn should_teardown_on_attested(self) -> bool {
604 self.should_teardown_on(ProcessPhase::Attested)
605 }
606
607 /// Symmetric delegate to [`Self::should_teardown_on`] for the
608 /// `Failed` case.
609 pub const fn should_teardown_on_failed(self) -> bool {
610 self.should_teardown_on(ProcessPhase::Failed)
611 }
612}
613
614// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
615// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
616// `pub struct UnknownTeardownPolicy(pub String)` are generated by
617// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
618// "as_str", display, generate_unknown)]` on the enum declaration above.
619// The auto-derived label `"teardown policy"` matches the prior hand-
620// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
621// inherent `as_str` projection stays load-bearing — the PascalCase
622// wire-format that matches the serde rename + the reconciler's reason-
623// string emission verbatim — while the trait method `label` gives
624// generic consumers a STABLE name across the 36+ workspace-wide
625// closed-set implementors.
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn default_lifetime_resolves_to_permanent() {
633 let l = Lifetime::default();
634 assert!(l.is_default());
635 assert!(!l.is_ephemeral());
636 assert!(matches!(
637 l.variant().unwrap(),
638 LifetimeVariant::Permanent(_)
639 ));
640 }
641
642 #[test]
643 fn ephemeral_set_resolves() {
644 // Routes through the ONE substrate composer
645 // [`Lifetime::ephemeral`] — see the composer's doc-comment for
646 // the full migration rationale.
647 let l = Lifetime::ephemeral(EphemeralLifetime::default());
648 assert!(l.is_ephemeral());
649 match l.variant().unwrap() {
650 LifetimeVariant::Ephemeral(e) => {
651 assert_eq!(e.ttl, "1h");
652 assert_eq!(e.teardown_policy, TeardownPolicy::Always);
653 assert_eq!(e.max_concurrent, 1);
654 }
655 other => panic!("expected ephemeral, got {other:?}"),
656 }
657 }
658
659 #[test]
660 fn ambiguous_lifetime_errors() {
661 let l = Lifetime {
662 permanent: Some(PermanentLifetime {}),
663 ephemeral: Some(EphemeralLifetime::default()),
664 };
665 assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
666 }
667
668 #[test]
669 fn teardown_policy_dispatch() {
670 assert!(TeardownPolicy::Always.should_teardown_on_attested());
671 assert!(TeardownPolicy::Always.should_teardown_on_failed());
672 assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
673 assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
674 assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
675 assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
676 assert!(!TeardownPolicy::Never.should_teardown_on_attested());
677 assert!(!TeardownPolicy::Never.should_teardown_on_failed());
678 }
679
680 // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
681 // should_teardown_on(phase)) ─
682
683 /// Structural well-formedness of [`TeardownPolicy`] as a
684 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
685 /// testkit lift that pins all three structural invariants (`ALL`
686 /// is non-empty, every variant round-trips through `label ↔
687 /// parse_label`, labels are pairwise distinct, `""` is outside the
688 /// closed set) at ONE call site. Replaces the hand-derived
689 /// `teardown_policy_all_is_unique_and_complete` +
690 /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
691 /// `unknown_teardown_policy_errors`. `FromStr` delegates to
692 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
693 /// exercises the same code path the reconciler hits when parsing a
694 /// CRD `enum:`-validated value back to the typed policy.
695 #[test]
696 fn teardown_policy_is_well_formed_closed_set() {
697 tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
698 }
699
700 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
701 /// output verbatim for every variant. A future variant rename
702 /// (or an `as_str` arm typo) lands here at one site. The reason
703 /// string `lifetime_clock::evaluate` stamps reaches for the same
704 /// projection via `Display`, so a Debug-vs-canonical drift would
705 /// surface here, not in operator-facing reason strings.
706 #[test]
707 fn teardown_policy_as_str_matches_serde() {
708 crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
709 }
710
711 /// The Display impl IS `as_str` — pinning this lets future
712 /// callers (notably `lifetime_clock::evaluate`'s reason string)
713 /// reach for either projection without drift.
714 #[test]
715 fn teardown_policy_display_matches_as_str() {
716 crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
717 }
718
719 /// `FromStr` rejects strings that aren't in the canonical
720 /// projection — lowercased / typo / unrelated — and the error
721 /// echoes the input verbatim so the operator-facing diagnostic
722 /// carries the offending value, not a normalized form. The
723 /// empty-input arm is pinned by
724 /// [`teardown_policy_is_well_formed_closed_set`] via the
725 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
726 /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
727 /// newtype, which the trait's `make_unknown` can't see.
728 #[test]
729 fn unknown_teardown_policy_errors() {
730 use std::str::FromStr;
731 for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
732 let err = TeardownPolicy::from_str(bad).unwrap_err();
733 assert_eq!(err.0, bad, "error payload should echo input verbatim");
734 }
735 }
736
737 /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
738 /// the documented (policy, phase) → bool table for every variant
739 /// at every typed phase. The two terminal phases (Attested,
740 /// Failed) carry the policy-specific result; every non-terminal
741 /// phase returns `false`. The closed-set sweep over both
742 /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
743 /// variant in either enum reaches this test by iteration — no
744 /// per-test array maintenance.
745 #[test]
746 fn teardown_policy_should_teardown_on_truth_table() {
747 for policy in TeardownPolicy::ALL {
748 for phase in ProcessPhase::ALL {
749 let expected = match phase {
750 ProcessPhase::Attested => {
751 matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
752 }
753 ProcessPhase::Failed => {
754 matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
755 }
756 _ => false,
757 };
758 assert_eq!(
759 policy.should_teardown_on(phase),
760 expected,
761 "should_teardown_on({policy:?}, {phase:?}) drift",
762 );
763 }
764 }
765 }
766
767 /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
768 /// `should_teardown_on_failed` predicates agree with the typed
769 /// `should_teardown_on(phase)` dispatch they delegate to, for
770 /// every variant. A regression that re-introduces an inline
771 /// `matches!` in either legacy predicate fails here the moment
772 /// `should_teardown_on` is the source of truth.
773 #[test]
774 fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
775 for policy in TeardownPolicy::ALL {
776 assert_eq!(
777 policy.should_teardown_on_attested(),
778 policy.should_teardown_on(ProcessPhase::Attested),
779 "Attested delegate drift for {policy:?}",
780 );
781 assert_eq!(
782 policy.should_teardown_on_failed(),
783 policy.should_teardown_on(ProcessPhase::Failed),
784 "Failed delegate drift for {policy:?}",
785 );
786 }
787 }
788
789 #[test]
790 fn serde_round_trip_ephemeral() {
791 // Routes through the ONE substrate composer
792 // [`Lifetime::ephemeral`] — see the composer's doc-comment for
793 // the full migration rationale.
794 let l = Lifetime::ephemeral(EphemeralLifetime {
795 ttl: "30m".into(),
796 teardown_policy: TeardownPolicy::OnAttested,
797 max_concurrent: 4,
798 exports: vec![],
799 });
800 let yaml = serde_yaml::to_string(&l).unwrap();
801 assert!(yaml.contains("ttl: 30m"));
802 assert!(yaml.contains("teardownPolicy: OnAttested"));
803 // Empty exports skip-serialize — explicit zero-trace default.
804 assert!(!yaml.contains("exports"));
805 let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
806 assert!(back.is_ephemeral());
807 assert!(back.ephemeral.unwrap().exports.is_empty());
808 }
809
810 #[test]
811 fn applicable_exports_filters_by_trigger() {
812 use crate::export::{
813 ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
814 VectorChannel,
815 };
816 let spec_attested = ExportSpec {
817 source: ArtifactSource {
818 receipts: Some(ReceiptsSource::default()),
819 ..ArtifactSource::default()
820 },
821 channel: VectorChannel {
822 http_event: Some(HttpEventChannel::signal("receipt")),
823 ..VectorChannel::default()
824 },
825 when: ExportTrigger::OnAttested,
826 experiment_id_override: None,
827 };
828 let spec_failed = ExportSpec {
829 when: ExportTrigger::OnFailed,
830 ..spec_attested.clone()
831 };
832 let spec_always = ExportSpec {
833 when: ExportTrigger::Always,
834 ..spec_attested.clone()
835 };
836
837 let lt = EphemeralLifetime {
838 ttl: "1h".into(),
839 teardown_policy: TeardownPolicy::OnAttested,
840 max_concurrent: 1,
841 exports: vec![spec_attested, spec_failed, spec_always],
842 };
843
844 // Attested gate fires OnAttested + Always — 2 of 3.
845 assert!(lt.has_applicable_exports(ProcessPhase::Attested));
846 assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
847
848 // Failed gate fires OnFailed + Always — 2 of 3.
849 assert!(lt.has_applicable_exports(ProcessPhase::Failed));
850 assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
851
852 // Other phases never route through Releasing.
853 for p in [
854 ProcessPhase::Pending,
855 ProcessPhase::Forking,
856 ProcessPhase::Execing,
857 ProcessPhase::Running,
858 ProcessPhase::Reconverging,
859 ProcessPhase::Releasing,
860 ProcessPhase::Exiting,
861 ProcessPhase::Zombie,
862 ProcessPhase::Reaped,
863 ] {
864 assert!(!lt.has_applicable_exports(p));
865 assert_eq!(lt.applicable_exports(p).count(), 0);
866 }
867 }
868
869 #[test]
870 fn no_exports_means_no_applicable_exports() {
871 let lt = EphemeralLifetime::default();
872 assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
873 assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
874 }
875
876 /// Structural well-formedness of [`LifetimeKind`] as a
877 /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-
878 /// wide testkit that pins ALL structural invariants (`ALL` is
879 /// non-empty, every variant round-trips through `label ↔
880 /// parse_label`, labels are pairwise distinct, `""` is outside
881 /// the closed set, the [`UnknownLifetimeKind`] carrier's Display
882 /// renders the substrate-wide `"unknown lifetime kind: <input>"`
883 /// shape, `labels()` equals the natural `ALL × label` projection)
884 /// at ONE call site. Subsumes the hand-derived
885 /// `lifetime_kind_all_is_unique_and_complete` sweep the pre-derive
886 /// site published — clauses (1)+(3) of the testkit fold uniqueness
887 /// + non-emptiness into the substrate primitive's own body.
888 #[test]
889 fn lifetime_kind_is_well_formed_closed_set() {
890 tatara_closed_set::assert_closed_set_well_formed::<LifetimeKind>();
891 }
892
893 /// The Display impl IS `as_str` — pinning this lets future callers
894 /// reach for either projection without drift. Symmetric to every
895 /// sibling `X_display_matches_as_str` invariant across
896 /// `tatara-process`; routes through the substrate primitive
897 /// [`crate::tagged_union::assert_display_matches_label`] shared
898 /// with all 29+ production Display-alignment sites. The auto-
899 /// derived `Display` body from `#[closed_set(via = "as_str",
900 /// display)]` emits the substrate-wide `f.write_str(Self::as_str
901 /// (*self))` shape — a regression that regresses `as_str` (or a
902 /// future hand-rolled Display block that drifts from `as_str`)
903 /// surfaces here at the substrate-wide alignment probe.
904 #[test]
905 fn lifetime_kind_display_matches_as_str() {
906 crate::tagged_union::assert_display_matches_label::<LifetimeKind>();
907 }
908
909 /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
910 /// camelCase serde field name on `Lifetime`. A future rename of
911 /// any field lands here at one site — and the wire-key alignment
912 /// stays coherent with the operator-facing serde shape.
913 ///
914 /// Routes through the substrate primitive
915 /// [`crate::tagged_union::assert_wire_key_matches_label`] — the
916 /// bound-relaxed peer of `assert_single_slot_key_matches_label`
917 /// that drops the `T: TaggedUnion` requirement so `Lifetime`
918 /// (whose empty variant resolves to `Permanent(&DEFAULT_PERMANENT)`
919 /// rather than to a [`crate::tagged_union::TaggedUnionError::empty`]
920 /// carrier) still binds through ONE substrate wire-key alignment
921 /// site. Pre-lift the body restated the same serialize +
922 /// exactly-one-key + name-equality sweep at this test surface
923 /// verbatim; post-lift the projection lives at ONE substrate
924 /// primitive and this site binds through a single call — the
925 /// same mechanical shape the four sibling TaggedUnion parents
926 /// carry via the trait-projected [`crate::tagged_union::assert_single_slot_key_matches_label`].
927 #[test]
928 fn lifetime_kind_as_str_matches_lifetime_field_name() {
929 crate::tagged_union::assert_wire_key_matches_label::<Lifetime, LifetimeKind, _>(
930 single_slot_lifetime,
931 );
932 }
933
934 /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
935 /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
936 /// projection composes the closed set in both directions — a
937 /// regression that misroutes a select arm (e.g. `Self::Permanent =>
938 /// l.ephemeral.as_ref()...`) fails loudly here.
939 #[test]
940 fn lifetime_kind_round_trips_through_variant_kind() {
941 for kind in LifetimeKind::ALL {
942 let l = single_slot_lifetime(kind);
943 let v = kind.select(&l).expect("populated slot must select");
944 assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
945 // And the resolver lands on the same variant.
946 assert_eq!(
947 l.variant().expect("exactly-one variant").kind(),
948 kind,
949 "variant() resolver disagreed on {kind:?}"
950 );
951 }
952 }
953
954 /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
955 /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
956 /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
957 /// previously hand-rolled.
958 #[test]
959 fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
960 let permanent = PermanentLifetime {};
961 let v = LifetimeVariant::Permanent(&permanent);
962 assert!(v.as_ephemeral().is_none());
963 assert!(v.as_permanent().is_some());
964
965 let ephemeral = EphemeralLifetime {
966 ttl: "42m".into(),
967 teardown_policy: TeardownPolicy::OnAttested,
968 max_concurrent: 3,
969 exports: vec![],
970 };
971 let v = LifetimeVariant::Ephemeral(&ephemeral);
972 let inner = v.as_ephemeral().expect("ephemeral must project");
973 assert_eq!(inner.ttl, "42m");
974 assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
975 assert_eq!(inner.max_concurrent, 3);
976 assert!(v.as_permanent().is_none());
977 }
978
979 /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
980 /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
981 /// iff the resolver picks the ephemeral slot unambiguously. All
982 /// three failure modes (empty → permanent default, permanent-only,
983 /// ambiguous) collapse to `None`, matching the pre-lift
984 /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
985 /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
986 ///
987 /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
988 /// permanent-only: an operator-authored spec with both slots
989 /// populated is a mis-configuration, and firing TTL / teardown on
990 /// it would be worse than skipping. Pinning that collapse here
991 /// closes the possibility of a future per-consumer drift where one
992 /// branch honors ambiguity (fires the timed action) and another
993 /// doesn't.
994 ///
995 /// The `Some` arm asserts byte-identity of the projected borrow
996 /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
997 /// silently swapped the projection to `self.permanent.as_ref()`
998 /// would surface here as a type mismatch rather than as a runtime
999 /// no-op in production.
1000 #[test]
1001 fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
1002 // 1. Empty (both slots None) — resolves to Permanent default.
1003 let l = Lifetime::default();
1004 assert!(l.resolved_ephemeral().is_none());
1005
1006 // 2. Permanent-only.
1007 // Routes through the ONE substrate composer
1008 // [`Lifetime::permanent`] — see the composer's doc-comment.
1009 let l = Lifetime::permanent();
1010 assert!(l.resolved_ephemeral().is_none());
1011
1012 // 3. Ephemeral-only — the ONE arm that projects.
1013 let ephemeral = EphemeralLifetime {
1014 ttl: "13m".into(),
1015 teardown_policy: TeardownPolicy::OnFailed,
1016 max_concurrent: 7,
1017 exports: vec![],
1018 };
1019 // Routes through the ONE substrate composer
1020 // [`Lifetime::ephemeral`] — see the composer's doc-comment.
1021 let l = Lifetime::ephemeral(ephemeral.clone());
1022 let e = l.resolved_ephemeral().expect("ephemeral-only must project");
1023 assert_eq!(e.ttl, "13m");
1024 assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
1025 assert_eq!(e.max_concurrent, 7);
1026 // The borrow points into `self.ephemeral`, not into a temporary.
1027 assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
1028
1029 // 4. Ambiguous (both slots set) — collapses to None, NOT to
1030 // the ephemeral inner. Guards against a future refactor
1031 // that silently unwrapped ambiguity to "prefer ephemeral".
1032 let l = Lifetime {
1033 permanent: Some(PermanentLifetime {}),
1034 ephemeral: Some(EphemeralLifetime::default()),
1035 };
1036 assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1037 assert!(l.resolved_ephemeral().is_none());
1038 }
1039
1040 /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
1041 /// set" outcome is `Permanent`, not an error. Pin via the
1042 /// closed-set kind projection so a future variant added to the
1043 /// closed set (and to the `Lifetime` struct) without updating
1044 /// the default resolution would surface here — the default
1045 /// stays `Permanent` regardless of the closed set's arity.
1046 #[test]
1047 fn empty_lifetime_resolves_to_permanent_kind() {
1048 let l = Lifetime::default();
1049 let v = l.variant().expect("default lifetime resolves");
1050 assert_eq!(v.kind(), LifetimeKind::Permanent);
1051 assert!(v.as_permanent().is_some());
1052 assert!(v.as_ephemeral().is_none());
1053 }
1054
1055 /// Construct a `Lifetime` with exactly the given kind's slot
1056 /// populated by a minimal valid inner spec. Shared across the
1057 /// closed-set property tests so they each cover every variant
1058 /// without restating the construction table.
1059 fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
1060 // Each arm routes through the ONE substrate composer for its
1061 // closed-set discriminator — [`Lifetime::permanent`] /
1062 // [`Lifetime::ephemeral`]. See their doc-comments for the
1063 // migration rationale.
1064 match kind {
1065 LifetimeKind::Permanent => Lifetime::permanent(),
1066 LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
1067 }
1068 }
1069
1070 #[test]
1071 fn exports_round_trip_through_lifetime() {
1072 use crate::export::{
1073 ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
1074 VectorChannel,
1075 };
1076 // Routes through the ONE substrate composer
1077 // [`Lifetime::ephemeral`] — see the composer's doc-comment.
1078 let l = Lifetime::ephemeral(EphemeralLifetime {
1079 ttl: "30m".into(),
1080 teardown_policy: TeardownPolicy::OnAttested,
1081 max_concurrent: 1,
1082 exports: vec![ExportSpec {
1083 source: ArtifactSource {
1084 receipts: Some(ReceiptsSource::default()),
1085 ..ArtifactSource::default()
1086 },
1087 channel: VectorChannel {
1088 http_event: Some(HttpEventChannel::signal("receipt")),
1089 ..VectorChannel::default()
1090 },
1091 when: ExportTrigger::OnAttested,
1092 experiment_id_override: None,
1093 }],
1094 });
1095 let yaml = serde_yaml::to_string(&l).unwrap();
1096 assert!(yaml.contains("exports:"));
1097 assert!(yaml.contains("receipts: {}"));
1098 assert!(yaml.contains("signalType: receipt"));
1099 let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
1100 let e = back.ephemeral.unwrap();
1101 assert_eq!(e.exports.len(), 1);
1102 assert!(e.exports[0].source.receipts.is_some());
1103 assert!(e.exports[0].channel.http_event.is_some());
1104 }
1105
1106 // ─── EphemeralLifetime::ttl_duration substrate pins ──────────────
1107 //
1108 // The `humantime::parse_duration(&<eph>.ttl).ok()` chain was open-
1109 // lifted from TWO consumer sites in `crate::lifetime_clock`
1110 // (`evaluate` + `requeue_with_ttl`) onto the ONE substrate
1111 // primitive [`EphemeralLifetime::ttl_duration`]. These pins bind
1112 // the primitive at the fail-before-pass-after level so a future
1113 // regression that swaps the return-form (an
1114 // `anyhow::Result<Duration>` — a per-consumer normalization gate
1115 // — a saturating `Duration::ZERO` for the parse-error corner)
1116 // fails HERE before landing at either consumer.
1117
1118 fn eph_with_ttl(ttl: &str) -> EphemeralLifetime {
1119 EphemeralLifetime {
1120 ttl: ttl.to_string(),
1121 teardown_policy: TeardownPolicy::default(),
1122 max_concurrent: 1,
1123 exports: Vec::new(),
1124 }
1125 }
1126
1127 /// The canonical shape every consumer rides through — a parseable
1128 /// humantime string projects to `Some(std::time::Duration)` matching
1129 /// the operator-authored `ttl` verbatim. Pin: the returned duration
1130 /// is EXACTLY what `humantime::parse_duration` produces for the
1131 /// same input, in `std::time::Duration` so the downstream
1132 /// `elapsed >= ttl` / `ttl.checked_sub(elapsed)` comparators land
1133 /// with both operands on the same axis without a per-consumer
1134 /// conversion.
1135 #[test]
1136 fn ttl_duration_parseable_humantime_projects_to_some() {
1137 for (ttl, expected) in [
1138 ("1h", std::time::Duration::from_secs(3600)),
1139 ("30m", std::time::Duration::from_secs(1800)),
1140 ("90s", std::time::Duration::from_secs(90)),
1141 ("5m30s", std::time::Duration::from_secs(330)),
1142 ("500ms", std::time::Duration::from_millis(500)),
1143 ] {
1144 assert_eq!(
1145 eph_with_ttl(ttl).ttl_duration(),
1146 Some(expected),
1147 "ttl_duration drift for {ttl:?}",
1148 );
1149 }
1150 }
1151
1152 /// The `None` arm is the "operator's ttl string doesn't parse"
1153 /// corner every consumer collapses to the skip-branch — a typo, an
1154 /// unsupported unit, an empty string, a free-form label that
1155 /// reached the field. Pin the boundary at the primitive so a
1156 /// future normalization can't silently substitute a default in
1157 /// place of the parse-failure signal.
1158 #[test]
1159 fn ttl_duration_unparseable_returns_none() {
1160 for bad in [
1161 // Empty string — the operator left the ttl blank.
1162 "", // Non-humantime literal — the operator wrote a foreign format.
1163 "forever", "1our",
1164 // Nonsense that looks numeric but isn't a humantime span.
1165 "abc",
1166 // Only-whitespace input — passes serde's non-empty gate but
1167 // doesn't parse as a duration.
1168 " ",
1169 ] {
1170 assert_eq!(
1171 eph_with_ttl(bad).ttl_duration(),
1172 None,
1173 "ttl_duration should be None for {bad:?}",
1174 );
1175 }
1176 }
1177
1178 /// Zero-duration edge — `"0s"` parses to `Duration::ZERO`, not
1179 /// `None`. Every consumer needs the zero-ttl EphemeralLifetime to
1180 /// count as "elapsed=0 already ≥ ttl=0" so a zero-TTL ephemeral
1181 /// expires on its own creation instant; swapping this arm to
1182 /// `None` would silently keep every zero-TTL Process alive past
1183 /// the TTL-expiry gate in [`crate::lifetime_clock::evaluate`].
1184 #[test]
1185 fn ttl_duration_zero_seconds_returns_some_zero() {
1186 assert_eq!(
1187 eph_with_ttl("0s").ttl_duration(),
1188 Some(std::time::Duration::ZERO),
1189 );
1190 }
1191
1192 /// Subsecond precision survives the parse — a regression that
1193 /// silently truncated to whole seconds would compare
1194 /// `elapsed = 500ms` against a `ttl_duration()` of `500ms` as
1195 /// `500ms >= 0s` (always fire) rather than `500ms >= 500ms`
1196 /// (fires on the boundary). Peer to the sibling substrate
1197 /// `elapsed_since` subsecond pin in `crate::time`.
1198 #[test]
1199 fn ttl_duration_preserves_subsecond_precision() {
1200 assert_eq!(
1201 eph_with_ttl("250ms").ttl_duration(),
1202 Some(std::time::Duration::from_millis(250)),
1203 );
1204 }
1205
1206 /// Default `EphemeralLifetime` carries the canonical `"1h"` ttl
1207 /// (matches `default_ttl()` at this file's top), so
1208 /// `.ttl_duration()` on it agrees with a manually-parsed `"1h"`
1209 /// pass through `humantime`. Pins the default-ttl contract at
1210 /// the substrate so a future default rename lands at ONE site
1211 /// (this ttl_duration pin + the `default_ttl` fn) without silent
1212 /// wall-clock drift at either consumer.
1213 #[test]
1214 fn ttl_duration_of_default_ephemeral_matches_1h() {
1215 let e = EphemeralLifetime::default();
1216 assert_eq!(e.ttl, "1h");
1217 assert_eq!(e.ttl_duration(), Some(std::time::Duration::from_secs(3600)));
1218 }
1219
1220 /// Byte-for-byte parity with the pre-lift hand-authored chain —
1221 /// `<eph>.ttl_duration()` produces the SAME
1222 /// `Option<std::time::Duration>` as the two-link `humantime::
1223 /// parse_duration(&<eph>.ttl).ok()` chain both `lifetime_clock`
1224 /// consumers walked pre-lift. A regression at THIS pin fails
1225 /// before it lands at either consumer as silent operator-facing
1226 /// skew between the TTL-expiry gate and the sleep-budget picker.
1227 #[test]
1228 fn ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
1229 for ttl in [
1230 "1h", "30m", "90s", "5m30s", "500ms", "0s", "1us", "forever", "", "1our",
1231 ] {
1232 let e = eph_with_ttl(ttl);
1233 let via_primitive = e.ttl_duration();
1234 let hand_authored = humantime::parse_duration(&e.ttl).ok();
1235 assert_eq!(
1236 via_primitive, hand_authored,
1237 "ttl_duration must be byte-identical to `humantime::\
1238 parse_duration(&self.ttl).ok()` for {ttl:?}",
1239 );
1240 }
1241 }
1242
1243 // ─── Lifetime::{permanent,ephemeral} substrate composer pins ─────
1244 //
1245 // The `Lifetime { permanent: Some(PermanentLifetime {}), .. }` +
1246 // `Lifetime { ephemeral: Some(<e>), .. }` shapes were open-lifted
1247 // from FOUR + ELEVEN+ hand-authored fixture literals onto the ONE
1248 // substrate composer pair [`Lifetime::permanent`] +
1249 // [`Lifetime::ephemeral`]. These pins bind the composers at the
1250 // fail-before-pass-after level so a future regression that flipped
1251 // either arm (a swapped slot assignment, a stray `Some` on the
1252 // opposite slot, a drift in the resolver's landing variant) fails
1253 // HERE before landing at any of the fifteen+ consumer sites.
1254
1255 /// Pre-lift the `Lifetime { permanent: Some(PermanentLifetime {}),
1256 /// ephemeral: None }` (equivalently `Lifetime { permanent:
1257 /// Some(PermanentLifetime {}), ..Lifetime::default() }`) shape had
1258 /// three surfaces every consumer paired: the resolver picks
1259 /// `Permanent`, the `is_ephemeral` gate reads `false`, and the
1260 /// two slots read as (`Some`, `None`). Bind all three from the
1261 /// composer in ONE assertion group.
1262 #[test]
1263 fn lifetime_permanent_composer_matches_pre_lift_shape_bytewise() {
1264 let via_primitive = Lifetime::permanent();
1265 let hand_authored = Lifetime {
1266 permanent: Some(PermanentLifetime {}),
1267 ephemeral: None,
1268 };
1269
1270 // Both slots read identically.
1271 assert!(via_primitive.permanent.is_some());
1272 assert!(hand_authored.permanent.is_some());
1273 assert!(via_primitive.ephemeral.is_none());
1274 assert!(hand_authored.ephemeral.is_none());
1275
1276 // is_default is false (permanent is set), is_ephemeral is false.
1277 assert!(!via_primitive.is_default());
1278 assert!(!via_primitive.is_ephemeral());
1279 assert_eq!(via_primitive.is_default(), hand_authored.is_default());
1280 assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
1281
1282 // Resolver lands on `Permanent`, not on `Ambiguous`.
1283 assert_eq!(
1284 via_primitive
1285 .variant()
1286 .expect("permanent-only resolves")
1287 .kind(),
1288 LifetimeKind::Permanent,
1289 );
1290
1291 // resolved_ephemeral projects to None (peer arm of the
1292 // ephemeral-only composer's Some(&e) landing).
1293 assert!(via_primitive.resolved_ephemeral().is_none());
1294 }
1295
1296 /// Pre-lift the `Lifetime { permanent: None, ephemeral: Some(<e>) }`
1297 /// (equivalently `Lifetime { ephemeral: Some(<e>), ..Lifetime::
1298 /// default() }`) shape had four surfaces every consumer paired: the
1299 /// resolver picks `Ephemeral(<e>)`, the `is_ephemeral` gate reads
1300 /// `true`, the two slots read as (`None`, `Some`), and
1301 /// [`Lifetime::resolved_ephemeral`] projects to `Some(&<e>)` with
1302 /// the SAME inner spec bytes the caller supplied. Bind all four
1303 /// from the composer in ONE assertion group.
1304 #[test]
1305 fn lifetime_ephemeral_composer_matches_pre_lift_shape_bytewise() {
1306 let inner = EphemeralLifetime {
1307 ttl: "17m".into(),
1308 teardown_policy: TeardownPolicy::OnFailed,
1309 max_concurrent: 5,
1310 exports: vec![],
1311 };
1312 let via_primitive = Lifetime::ephemeral(inner.clone());
1313 let hand_authored = Lifetime {
1314 permanent: None,
1315 ephemeral: Some(inner.clone()),
1316 };
1317
1318 // Both slots read identically.
1319 assert!(via_primitive.permanent.is_none());
1320 assert!(hand_authored.permanent.is_none());
1321 assert!(via_primitive.ephemeral.is_some());
1322 assert!(hand_authored.ephemeral.is_some());
1323
1324 // is_default is false, is_ephemeral is true.
1325 assert!(!via_primitive.is_default());
1326 assert!(via_primitive.is_ephemeral());
1327 assert_eq!(via_primitive.is_default(), hand_authored.is_default());
1328 assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
1329
1330 // Resolver lands on `Ephemeral` with the SAME inner bytes.
1331 let via_inner = via_primitive
1332 .resolved_ephemeral()
1333 .expect("ephemeral-only resolves to Some(&e)");
1334 assert_eq!(via_inner.ttl, inner.ttl);
1335 assert_eq!(via_inner.teardown_policy, inner.teardown_policy);
1336 assert_eq!(via_inner.max_concurrent, inner.max_concurrent);
1337
1338 // Kind projects to `Ephemeral`.
1339 assert_eq!(
1340 via_primitive
1341 .variant()
1342 .expect("ephemeral-only resolves")
1343 .kind(),
1344 LifetimeKind::Ephemeral,
1345 );
1346 }
1347
1348 /// The two composers PARTITION the closed set — every
1349 /// `LifetimeKind` variant is reachable by exactly ONE composer,
1350 /// and the composer's landing kind matches the discriminator. A
1351 /// future third variant added to `LifetimeKind` without a paired
1352 /// composer would surface here (the exhaustive `ALL` sweep would
1353 /// hit a case with no arm to construct through).
1354 #[test]
1355 fn lifetime_composers_cover_every_non_ambiguous_closed_set_arm() {
1356 for kind in LifetimeKind::ALL {
1357 let via_composer = match kind {
1358 LifetimeKind::Permanent => Lifetime::permanent(),
1359 LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
1360 };
1361 let resolved = via_composer
1362 .variant()
1363 .expect("composer output resolves unambiguously")
1364 .kind();
1365 assert_eq!(
1366 resolved, kind,
1367 "composer for {kind:?} must land on the SAME resolver kind",
1368 );
1369 }
1370 }
1371
1372 /// Neither composer produces the ambiguous corner — the composer's
1373 /// contract is "exactly one slot set", and the ambiguous case
1374 /// [`LifetimeError::Ambiguous`] must be unreachable through them.
1375 /// A future refactor that widened either composer to accept the
1376 /// opposite slot (e.g. added a `permanent_with_ephemeral_override`
1377 /// arm) would surface here.
1378 #[test]
1379 fn lifetime_composers_never_produce_ambiguous_variant() {
1380 assert!(
1381 Lifetime::permanent().variant().is_ok(),
1382 "Lifetime::permanent must never resolve to Ambiguous",
1383 );
1384 assert!(
1385 Lifetime::ephemeral(EphemeralLifetime::default())
1386 .variant()
1387 .is_ok(),
1388 "Lifetime::ephemeral must never resolve to Ambiguous",
1389 );
1390 }
1391}