Skip to main content

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    /// Closed-set-driven presence probe — does this [`Lifetime`] carry a
286    /// populated slot addressed by the given [`LifetimeKind`]
287    /// discriminator? The inherent peer of
288    /// [`crate::tagged_union::TaggedUnion::has`] on the
289    /// closed-set-driven presence-probe axis.
290    ///
291    /// # Why an inherent method
292    ///
293    /// [`Lifetime`] deliberately does NOT impl [`crate::tagged_union::TaggedUnion`]
294    /// — its resolver returns `Ok(Permanent(&DEFAULT_PERMANENT))` on the
295    /// empty (no-slot-populated) input rather than the trait's
296    /// [`crate::tagged_union::TaggedUnionError::empty`] carrier, so the
297    /// trait's `<T: TaggedUnion>::has` default body is unreachable
298    /// through the trait boundary. This inherent method mirrors the
299    /// trait's default body verbatim (`kind.select(self).is_some()`) so
300    /// every closed-set-driven presence-probe dispatch table (a
301    /// `lifetime-<kind>` require-tag sweep in tatara-check parallel to
302    /// the `intent-<kind>` family, a future audit binary enumerating
303    /// Processes by lifetime kind, a fleet-side migration sweep that
304    /// picks up a new `LifetimeKind::Burst` variant automatically) binds
305    /// through the SAME shape both `Lifetime` and every `TaggedUnion`
306    /// implementor on `ProcessSpec` publish.
307    ///
308    /// # Semantics — POPULATED slot, not RESOLVED variant
309    ///
310    /// `has(kind)` returns `true` iff the field addressed by `kind` on
311    /// this [`Lifetime`] is `Some(_)`. This is byte-identical to the
312    /// pre-lift `self.<field>.is_some()` shape [`Self::is_ephemeral`]
313    /// walked, and matches [`crate::intent::Intent::has`]'s semantics
314    /// on `ProcessSpec`.
315    ///
316    /// A [`Lifetime`] with both slots [`None`] returns `false` for
317    /// EVERY [`LifetimeKind`] — even though [`Self::variant`] would
318    /// resolve it to `Ok(Permanent)` via the default fallback. The two
319    /// probes answer distinct questions: `has(Permanent)` asks "is the
320    /// permanent slot populated" (write-side spec detail);
321    /// `variant().ok().map(|v| v.kind()) == Some(Permanent)` asks "does
322    /// the resolver pick Permanent" (read-side operational answer). A
323    /// caller that wants the latter composes it through [`Self::variant`]
324    /// directly.
325    ///
326    /// # `LifetimeKind::select`
327    ///
328    /// Delegates through [`LifetimeKind::select`] so a new variant
329    /// added to the closed set (e.g. `Burst` for budget-capped non-TTL
330    /// lifetimes) reaches this probe through the SAME closed-set-driven
331    /// dispatch as every other consumer that walks
332    /// [`LifetimeKind::ALL`]. Rustc's exhaustiveness check on
333    /// [`LifetimeKind::select`]'s match forces the new arm at ONE site
334    /// and this probe picks up the new variant mechanically without
335    /// per-caller edit.
336    ///
337    /// # Sibling to [`crate::intent::Intent::has`]
338    ///
339    /// Same shape, same axis, same body on the sibling closed-set
340    /// discriminator [`crate::intent::IntentKind`]. `Intent::has` is
341    /// macro-emitted through [`crate::declare_tagged_union_impls!`] on
342    /// the trait-implementor path; this method is hand-authored on the
343    /// non-trait-implementor path with the byte-identical body. A future
344    /// unification (a trait for closed-set-driven presence probes that
345    /// admits BOTH the resolver-defaulting and error-carrier flavors)
346    /// lands as ONE peer trait alongside [`crate::tagged_union::TaggedUnion`]
347    /// with both sites picking up the trait default in lockstep.
348    ///
349    /// Theory anchor: THEORY.md §II.1 invariant 5 (composition preserves
350    /// proofs — the presence-probe body lives at ONE substrate site so
351    /// every downstream `<xxx>-<kind>` requires-tag surface, closed-set
352    /// audit dispatcher, and future variant addition binds through the
353    /// SAME shape). THEORY.md §VI.1 (generation over composition — a
354    /// third [`LifetimeKind`] variant lands at ONE `ALL` + ONE
355    /// [`LifetimeKind::select`] arm and the presence probe picks it
356    /// up mechanically without further per-consumer edits).
357    #[must_use]
358    pub fn has(&self, kind: LifetimeKind) -> bool {
359        kind.select(self).is_some()
360    }
361
362    /// True iff `ephemeral` is set.
363    ///
364    /// Delegates through [`Self::has`] so the (POPULATED slot, closed-
365    /// set discriminator) shape lives at ONE substrate primitive on
366    /// [`Lifetime`]. Pre-lift the body was the direct `self.ephemeral
367    /// .is_some()` slot probe; post-lift the body composes the closed-
368    /// set-driven `has(LifetimeKind::Ephemeral)` primitive so a future
369    /// normalization at the presence-probe shape (a widened return
370    /// carrying the resolver's variant on the populated path, a
371    /// debug-build assertion that the caller hasn't stamped both slots,
372    /// a per-fleet warn on ambiguous lifetime specs) lands at ONE site
373    /// and this inherent forwarder + every other `has(kind)` consumer
374    /// picks up the shift mechanically.
375    #[must_use]
376    pub fn is_ephemeral(&self) -> bool {
377        self.has(LifetimeKind::Ephemeral)
378    }
379
380    /// Compound projection: `Some(&e)` iff [`Self::variant`] resolves
381    /// unambiguously to `Ephemeral(e)`; `None` for every other outcome
382    /// (empty → `Permanent` default, `Permanent` slot only, or
383    /// [`LifetimeError::Ambiguous`] when BOTH slots are set).
384    ///
385    /// The ambiguous case is deliberately collapsed to `None`: an
386    /// operator-authored spec with both `permanent:` and `ephemeral:`
387    /// populated is a mis-configuration, and every production consumer
388    /// of the pair [`crate::lifetime_clock::evaluate`] +
389    /// [`crate::lifetime_clock::requeue_with_ttl`] previously
390    /// hand-rolled the SAME two-step projection
391    /// (`variant().ok()?.as_ephemeral()`) whose Err-arm and
392    /// Permanent-arm both fell through to the same "no ephemeral
393    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
394    /// Lifting that chained collapse to ONE substrate primitive puts
395    /// "the ephemeral spec now, iff the resolver picked it" behind a
396    /// single call site and closes the possibility of a per-consumer
397    /// drift where one branch honors ambiguity and the other doesn't.
398    ///
399    /// A future third variant added to `Lifetime` (e.g. `Burst` for
400    /// budget-capped non-TTL lifetimes) reaches this projection
401    /// through the SAME [`Self::variant`] resolver + the SAME
402    /// [`LifetimeVariant::as_ephemeral`] discriminator, so the
403    /// ephemeral-only projection stays intact without a new arm here.
404    ///
405    /// Pinned by
406    /// `resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot`.
407    pub fn resolved_ephemeral(&self) -> Option<&EphemeralLifetime> {
408        // Pattern-match on the owned `LifetimeVariant` (not
409        // `variant.as_ephemeral()`) so the returned borrow carries the
410        // resolver's `'_self` lifetime through directly instead of the
411        // shorter borrow `as_ephemeral(&self)` synthesizes on the
412        // temporary variant. Symmetric peer discriminator arm
413        // `LifetimeVariant::as_ephemeral` still owns the closed-set
414        // projection for consumers that hold the variant by borrow;
415        // this projection is the compound-lift entry point for
416        // consumers whose call graph starts from `&Lifetime`.
417        match self.variant().ok()? {
418            LifetimeVariant::Ephemeral(e) => Some(e),
419            LifetimeVariant::Permanent(_) => None,
420        }
421    }
422}
423
424const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
425
426/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
427/// SIGTERM terminates only on explicit operator action.
428#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
429#[serde(rename_all = "camelCase")]
430pub struct PermanentLifetime {}
431
432/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
433///
434/// Phase semantics:
435/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
436///   reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
437/// - On `Failed`  with `teardown_policy ∈ {OnFailed,   Always}`:
438///   same. Otherwise Process stays at Failed for forensic inspection.
439/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
440///   reconcile loop tick. TTL expiry while in any non-terminal phase
441///   forces SIGTERM regardless of `teardown_policy`.
442#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
443#[serde(rename_all = "camelCase")]
444pub struct EphemeralLifetime {
445    /// `humantime`-parseable duration from `phaseSince(Forking)` after
446    /// which the Process is force-SIGTERM'd.
447    #[serde(default = "default_ephemeral_ttl")]
448    pub ttl: String,
449
450    /// When the Process auto-terminates.
451    #[serde(default)]
452    pub teardown_policy: TeardownPolicy,
453
454    /// Cluster-wide concurrency budget across ephemeral Processes that
455    /// share the same `spec.identity.name_override` / chart_ref.
456    /// `0` = no cap. Enforced by the reconciler before transitioning out
457    /// of `Pending`.
458    #[serde(default = "default_ephemeral_max_concurrent")]
459    pub max_concurrent: u32,
460
461    /// Declared exports — what artifacts survive teardown and where
462    /// they flow. Empty (default) = nothing survives, matching the
463    /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
464    /// independently triggered during the reconciler's `Releasing`
465    /// phase against the terminal `ProcessPhase` reached.
466    ///
467    /// See [`crate::export`] for the full type. All exports flow
468    /// through the pleme-io Vector + NATS layer — there is no
469    /// per-spec ad-hoc sink.
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub exports: Vec<ExportSpec>,
472}
473
474impl EphemeralLifetime {
475    /// The [`humantime`]-parsed `self.ttl` duration, or `None` if the
476    /// operator-authored `ttl` string doesn't parse — the one-line
477    /// collapse of the `humantime::parse_duration(&<eph>.ttl).ok()`
478    /// chain lifted to ONE typed owner past the ★★ PRIME-DIRECTIVE
479    /// ≥ 2 duplication threshold.
480    ///
481    /// Pre-lift the SAME chain was hand-authored at TWO workspace-wide
482    /// consumer sites in [`crate::lifetime_clock`], both walking
483    /// `humantime::parse_duration(&<ephemeral>.ttl)` on an
484    /// `&EphemeralLifetime` and discarding the parse-error arm to the
485    /// downstream "skip the timed decision" branch:
486    ///
487    /// * [`crate::lifetime_clock::evaluate`] — the TTL-expiry gate.
488    ///   Reads `if let Ok(ttl) = humantime::parse_duration(&ephemeral
489    ///   .ttl) { … }` inside the non-terminal-phase guard, comparing
490    ///   the parsed `Duration` against the wall-clock elapsed distance
491    ///   from `metadata.creation_timestamp` to fire
492    ///   `AutoTerminate::Now { TtlExpired }`.
493    /// * [`crate::lifetime_clock::requeue_with_ttl`] — the sleep-
494    ///   budget picker for the reconciler's next requeue. Reads
495    ///   `let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
496    ///   return default; };` and short-circuits to the caller's
497    ///   `default` sleep budget on parse failure.
498    ///
499    /// Both sites walked the SAME `humantime::parse_duration(&<eph>
500    /// .ttl)` chain and both wanted the Option-shape (the `Ok` arm as
501    /// the parsed `Duration`, the `Err` arm collapsed to the
502    /// downstream skip-branch). Post-lift each caller reaches for
503    /// `<eph>.ttl_duration()` and applies its own tail at its own
504    /// site (`if let Some(ttl) = …` for the guard, `let Some(ttl) =
505    /// … else { return default; }` for the sleep-budget picker).
506    ///
507    /// Return-form axis: `Option<std::time::Duration>` matches the
508    /// downstream comparator's type. The peer projection
509    /// [`crate::time::elapsed_since`] returns the SAME
510    /// `Option<std::time::Duration>` shape, so the TTL-expiry gate's
511    /// `elapsed >= ttl` comparator and the sleep-budget picker's
512    /// `ttl.checked_sub(elapsed)` subtraction each land with both
513    /// operands on the same axis, no per-consumer conversion.
514    ///
515    /// The `None` arm is the "operator's ttl string doesn't parse"
516    /// corner — a typo (`"1our"`), an unsupported unit, a
517    /// non-humantime literal that reached the field. Every consumer
518    /// interprets the corner as "no ttl data → don't fire the timed
519    /// decision" — [`crate::lifetime_clock::evaluate`] skips the
520    /// `AutoTerminate::Now` branch, [`crate::lifetime_clock::
521    /// requeue_with_ttl`] returns the caller's `default` sleep
522    /// budget. The pins below bind that shape.
523    ///
524    /// A future normalization (a per-fleet minimum TTL floor before
525    /// the humantime cast, a canonical unit-normalization pass, a
526    /// warn-log on unparseable strings) lands at THIS ONE substrate
527    /// primitive and every downstream ephemeral-TTL consumer inherits
528    /// the upgrade mechanically — no per-site edit at either of the
529    /// TWO listed callers or at future consumers (an allocation-TTL
530    /// remaining-budget picker, a pool free-TTL floor gate, a
531    /// stable-name claim-arbiter max-age tie-break).
532    ///
533    /// Sibling substrate primitive on the same
534    /// `(humantime string × Option<Duration>) → Option<Duration>`
535    /// axis: [`crate::time::elapsed_since`] — the `(now, anchor) →
536    /// Option<Duration>` peer that every timed-decision gate
537    /// composes with THIS primitive to produce an `elapsed >= ttl` /
538    /// `ttl.checked_sub(elapsed)` comparison.
539    ///
540    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
541    /// the `humantime::parse_duration(&<eph>.ttl).ok()` chain recurred
542    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
543    /// duplication trigger, and is lifted to ONE owner here).
544    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
545    /// the pins bind the parse-failure corner AND the empty-ttl corner
546    /// AND the humantime edge shapes AND the return-form parity with
547    /// [`crate::time::elapsed_since`], so a regression that drifts any
548    /// surface fails at `tests::ttl_duration_*` here rather than as
549    /// silent operator-facing skew between the TTL-expiry gate and
550    /// the sleep-budget picker on the SAME EphemeralLifetime).
551    #[must_use]
552    pub fn ttl_duration(&self) -> Option<std::time::Duration> {
553        humantime::parse_duration(&self.ttl).ok()
554    }
555
556    /// True iff any declared export's [`crate::export::ExportTrigger`]
557    /// fires for the given terminal-reached phase. The reconciler
558    /// uses this to decide whether to route `Attested`/`Failed`
559    /// through `Releasing` (the export window) or skip straight to
560    /// `Exiting`/`Zombie`.
561    ///
562    /// Returns `false` when the export list is empty or no trigger
563    /// matches — both cases collapse to the existing teardown path.
564    pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
565        self.exports.iter().any(|e| e.when.fires_on(phase))
566    }
567
568    /// Iterate over the exports whose trigger fires on `phase`.
569    /// The reconciler's `handle_releasing` consumes this to emit
570    /// one tatara-export-worker Job per surviving spec.
571    pub fn applicable_exports(
572        &self,
573        phase: ProcessPhase,
574    ) -> impl Iterator<Item = &ExportSpec> + '_ {
575        self.exports.iter().filter(move |e| e.when.fires_on(phase))
576    }
577}
578
579impl Default for EphemeralLifetime {
580    fn default() -> Self {
581        Self {
582            ttl: default_ephemeral_ttl(),
583            teardown_policy: TeardownPolicy::default(),
584            max_concurrent: default_ephemeral_max_concurrent(),
585            exports: Vec::new(),
586        }
587    }
588}
589
590/// Workspace-canonical humantime default TTL for every ephemeral
591/// authoring surface — the ONE substrate owner of the `"1h"` wire-form
592/// default that pre-lift lived as THREE identical private
593/// `fn default_ttl() -> String { "1h".to_string() }` shims across
594/// [`tatara-process`]'s own [`EphemeralLifetime`] + [`crate::ephemeral::
595/// EphemeralSpec`] + [`tatara-reconciler`]'s `EphemeralDefaults`.
596///
597/// Pre-lift the SAME string wire-form `"1h"` was serde-defaulted at
598/// THREE workspace-wide `#[serde(default = "default_ttl")]` slots past
599/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each carrying its
600/// own private `fn default_ttl() -> String { "1h".to_string() }` shim:
601///
602/// * [`EphemeralLifetime::ttl`] (this module) — the canonical
603///   lifetime-slot default. Round-tripped through
604///   [`EphemeralLifetime::default`] and every serde-deserialize of a
605///   `spec.lifetime.ephemeral` block whose `ttl:` field is omitted.
606/// * [`crate::ephemeral::EphemeralSpec::ttl`] — the `(defephemeral …)`
607///   Lisp authoring surface's serde default, the wire-form default a
608///   `defephemeral` form binds when the operator omits `:ttl`.
609/// * `tatara-reconciler::ephemeral_defaults::EphemeralDefaults::default_ttl`
610///   — the reconciler's operator-configured cluster-wide default TTL,
611///   which itself defaults to `"1h"` when the operator omits it from
612///   the shikumi config file.
613///
614/// All three shims returned bytewise-identical `"1h"` and served the
615/// SAME wire-form default. Any operator-facing re-tuning of the
616/// workspace-canonical default (a shift to `"30m"` for tighter env
617/// recycling, a shift to `"6h"` for long-running attestation suites,
618/// a per-fleet override sourced from a substrate config) pre-lift
619/// required a THREE-site coordinated edit; any drift silently produced
620/// operator-visible skew where `defephemeral :ttl <omitted>` defaulted
621/// to X but the reconciler's own default landed at Y for the SAME
622/// authoring surface. Post-lift every serde slot reads through this
623/// ONE substrate owner and the invariant "every ephemeral-default
624/// surface names the SAME humantime string" holds by construction.
625///
626/// Return-form axis: `String` — matches the serde `default = "…"` slot
627/// contract exactly (serde invokes the named function and stamps its
628/// returned owned value into the field). The paired [`DEFAULT_EPHEMERAL_TTL`]
629/// const exposes the underlying `&'static str` for callers that want
630/// the zero-allocation handle (a compile-time `assert_eq!` pin, a
631/// format-string argument, an ephemeral-context error message).
632///
633/// A future normalization on the workspace-canonical ephemeral TTL
634/// default (a fleet-wide re-tuning, a per-cluster override injected
635/// via a `TATARA_DEFAULT_EPHEMERAL_TTL` env var, a bounded-precision
636/// canonicalization to a specific humantime spelling like `"3600s"`)
637/// lands at THIS ONE substrate primitive and every downstream serde-
638/// default consumer inherits the upgrade mechanically — no per-site
639/// edit at any of the THREE listed callers or at future consumers (a
640/// new ephemeral-adjacent authoring surface, a fleet-wide dashboard
641/// that reads the canonical default, a new tatara-eval fixture).
642///
643/// Peer to [`default_ephemeral_max_concurrent`] on the "workspace-
644/// canonical ephemeral defaults" axis — both lift a THREE-way-
645/// duplicated (TTL) or TWO-way-duplicated (max-concurrent) private
646/// `fn default_*` shim onto ONE substrate owner. The paired
647/// [`DEFAULT_EPHEMERAL_TTL`] const and [`DEFAULT_EPHEMERAL_MAX_CONCURRENT`]
648/// const partition the same axis on the "typed handle over the
649/// wire-form default" side.
650///
651/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
652/// `"1h".to_string()` wire-form default recurred at THREE hand-authored
653/// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning
654/// two workspace crates, and is lifted onto ONE workspace-wide
655/// substrate owner here). THEORY.md §II.1 invariant 5 (composition
656/// preserves proofs — the pins bind the default at fail-before-pass-
657/// after granularity so a regression that drifted the wire-form
658/// surfaces at [`tests::default_ephemeral_ttl_matches_pre_lift_1h_string`]
659/// rather than as silent operator-facing skew across the three
660/// downstream consumers).
661#[must_use]
662pub fn default_ephemeral_ttl() -> String {
663    DEFAULT_EPHEMERAL_TTL.to_string()
664}
665
666/// Workspace-canonical humantime default TTL wire-form — the `&'static
667/// str` handle over the same `"1h"` value [`default_ephemeral_ttl`]
668/// returns. Use this const for compile-time comparisons and format
669/// arguments; use [`default_ephemeral_ttl`] for the owned `String` the
670/// serde `default = "…"` slot contract expects.
671pub const DEFAULT_EPHEMERAL_TTL: &str = "1h";
672
673/// Workspace-canonical default cluster-wide concurrency budget for
674/// every ephemeral authoring surface — the ONE substrate owner of the
675/// `1` wire-form default that pre-lift lived as TWO identical private
676/// `fn default_max_concurrent() -> u32 { 1 }` shims across
677/// [`EphemeralLifetime`] + [`crate::ephemeral::EphemeralSpec`].
678///
679/// Pre-lift the SAME `1u32` wire-form was serde-defaulted at TWO
680/// `tatara-process` `#[serde(default = "default_max_concurrent")]`
681/// slots past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each
682/// carrying its own private shim:
683///
684/// * [`EphemeralLifetime::max_concurrent`] (this module) — the
685///   lifetime-slot default: "at most one ephemeral Process per
686///   `spec.identity.name_override` / chart_ref concurrently until the
687///   operator explicitly widens the budget".
688/// * [`crate::ephemeral::EphemeralSpec::max_concurrent`] — the
689///   `(defephemeral …)` Lisp authoring surface's serde default, the
690///   same conservative "one at a time" invariant a fresh `defephemeral`
691///   binds when the operator omits `:max-concurrent`.
692///
693/// Both shims returned `1` and served the SAME "one-at-a-time"
694/// concurrency invariant. Post-lift both serde slots read through
695/// this ONE substrate owner; a future re-tuning of the workspace-
696/// canonical concurrency invariant (a shift to `2` for parallel-safe
697/// probes, a shift to `0` for uncapped ephemeral fleets, a per-fleet
698/// override) lands at ONE site and both downstream consumers inherit
699/// the upgrade mechanically.
700///
701/// NOT the same axis as `tatara-reconciler::ephemeral_defaults::
702/// EphemeralDefaults::max_concurrent_per_cluster`, which defaults to
703/// `0` (no cap) on purpose — that field is the operator's cluster-
704/// wide ceiling, whereas THIS default is the per-authoring-surface
705/// conservative "one-at-a-time" invariant. The two defaults are
706/// deliberately different values on deliberately different axes and
707/// stay separate.
708///
709/// Peer to [`default_ephemeral_ttl`] on the "workspace-canonical
710/// ephemeral defaults" axis — see that primitive's doc for the shared
711/// motivation.
712#[must_use]
713pub fn default_ephemeral_max_concurrent() -> u32 {
714    DEFAULT_EPHEMERAL_MAX_CONCURRENT
715}
716
717/// Workspace-canonical default cluster-wide concurrency budget wire-
718/// form — the `u32` handle over the same `1` value
719/// [`default_ephemeral_max_concurrent`] returns. Use this const for
720/// compile-time comparisons; use [`default_ephemeral_max_concurrent`]
721/// for the serde `default = "…"` slot contract.
722pub const DEFAULT_EPHEMERAL_MAX_CONCURRENT: u32 = 1;
723
724/// When an ephemeral Process self-terminates.
725///
726/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
727/// foreign success/failure language — typed phases are the source of truth.
728#[derive(
729    Clone,
730    Copy,
731    Debug,
732    PartialEq,
733    Eq,
734    Hash,
735    Serialize,
736    Deserialize,
737    JsonSchema,
738    Default,
739    tatara_closed_set::DeriveClosedSet,
740)]
741#[serde(rename_all = "PascalCase")]
742#[closed_set(via = "as_str", display, generate_unknown)]
743pub enum TeardownPolicy {
744    /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
745    #[default]
746    Always,
747    /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
748    OnAttested,
749    /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
750    /// TTL or explicit operator SIGTERM.
751    OnFailed,
752    /// Never auto-terminate (TTL still applies).
753    Never,
754}
755
756impl TeardownPolicy {
757    /// The closed set of teardown policies — single source of truth that
758    /// drives the `as_str` / Display / `FromStr` triad and the typed
759    /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
760    /// variant lands at one `ALL` entry + one `as_str` arm + one
761    /// `should_teardown_on` arm — exhaustively checked by the compiler
762    /// (the `[Self; 4]` array literal forces the arity).
763    ///
764    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
765    /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
766    /// [`crate::boundary::ConditionKind::ALL`],
767    /// [`crate::phase::ProcessPhase::ALL`],
768    /// [`crate::signal::ProcessSignal::ALL`].
769    pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
770
771    /// Canonical PascalCase wire-format projection — matches the serde
772    /// `rename_all = "PascalCase"` output verbatim. Used by Display
773    /// (single source of truth), by `FromStr` to identify the variant
774    /// from its annotation / status-field representation, and by
775    /// operator-facing reason strings the reconciler stamps without
776    /// reaching for `{:?}` Debug formatting. Pinned by
777    /// `teardown_policy_as_str_matches_serde`.
778    pub const fn as_str(self) -> &'static str {
779        match self {
780            Self::Always => "Always",
781            Self::OnAttested => "OnAttested",
782            Self::OnFailed => "OnFailed",
783            Self::Never => "Never",
784        }
785    }
786
787    /// True iff, given a `ProcessPhase`, this policy says "tear down."
788    /// ONE typed dispatch over the typed phase enum that replaces the
789    /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
790    /// predicates `lifetime_clock::evaluate` previously branched on.
791    /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
792    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
793    /// always return `false` — teardown is a terminal-phase decision.
794    ///
795    /// The legacy [`Self::should_teardown_on_attested`] /
796    /// [`Self::should_teardown_on_failed`] predicates remain as thin
797    /// delegates so existing call sites keep their narrow signatures;
798    /// the truth table is pinned by
799    /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
800    pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
801        match phase {
802            ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
803            ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
804            ProcessPhase::Pending
805            | ProcessPhase::Forking
806            | ProcessPhase::Execing
807            | ProcessPhase::Running
808            | ProcessPhase::Reconverging
809            | ProcessPhase::Releasing
810            | ProcessPhase::Exiting
811            | ProcessPhase::Zombie
812            | ProcessPhase::Reaped => false,
813        }
814    }
815
816    /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
817    /// case — kept so existing call sites (notably the truth-table
818    /// test in this module) keep their narrow signature without
819    /// reaching for the typed-phase variant.
820    pub const fn should_teardown_on_attested(self) -> bool {
821        self.should_teardown_on(ProcessPhase::Attested)
822    }
823
824    /// Symmetric delegate to [`Self::should_teardown_on`] for the
825    /// `Failed` case.
826    pub const fn should_teardown_on_failed(self) -> bool {
827        self.should_teardown_on(ProcessPhase::Failed)
828    }
829}
830
831// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
832// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
833// `pub struct UnknownTeardownPolicy(pub String)` are generated by
834// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
835// "as_str", display, generate_unknown)]` on the enum declaration above.
836// The auto-derived label `"teardown policy"` matches the prior hand-
837// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
838// inherent `as_str` projection stays load-bearing — the PascalCase
839// wire-format that matches the serde rename + the reconciler's reason-
840// string emission verbatim — while the trait method `label` gives
841// generic consumers a STABLE name across the 36+ workspace-wide
842// closed-set implementors.
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn default_lifetime_resolves_to_permanent() {
850        let l = Lifetime::default();
851        assert!(l.is_default());
852        assert!(!l.is_ephemeral());
853        assert!(matches!(
854            l.variant().unwrap(),
855            LifetimeVariant::Permanent(_)
856        ));
857    }
858
859    #[test]
860    fn ephemeral_set_resolves() {
861        // Routes through the ONE substrate composer
862        // [`Lifetime::ephemeral`] — see the composer's doc-comment for
863        // the full migration rationale.
864        let l = Lifetime::ephemeral(EphemeralLifetime::default());
865        assert!(l.is_ephemeral());
866        match l.variant().unwrap() {
867            LifetimeVariant::Ephemeral(e) => {
868                assert_eq!(e.ttl, "1h");
869                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
870                assert_eq!(e.max_concurrent, 1);
871            }
872            other => panic!("expected ephemeral, got {other:?}"),
873        }
874    }
875
876    #[test]
877    fn ambiguous_lifetime_errors() {
878        let l = Lifetime {
879            permanent: Some(PermanentLifetime {}),
880            ephemeral: Some(EphemeralLifetime::default()),
881        };
882        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
883    }
884
885    #[test]
886    fn teardown_policy_dispatch() {
887        assert!(TeardownPolicy::Always.should_teardown_on_attested());
888        assert!(TeardownPolicy::Always.should_teardown_on_failed());
889        assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
890        assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
891        assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
892        assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
893        assert!(!TeardownPolicy::Never.should_teardown_on_attested());
894        assert!(!TeardownPolicy::Never.should_teardown_on_failed());
895    }
896
897    // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
898    //    should_teardown_on(phase)) ─
899
900    /// Structural well-formedness of [`TeardownPolicy`] as a
901    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
902    /// testkit lift that pins all three structural invariants (`ALL`
903    /// is non-empty, every variant round-trips through `label ↔
904    /// parse_label`, labels are pairwise distinct, `""` is outside the
905    /// closed set) at ONE call site. Replaces the hand-derived
906    /// `teardown_policy_all_is_unique_and_complete` +
907    /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
908    /// `unknown_teardown_policy_errors`. `FromStr` delegates to
909    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
910    /// exercises the same code path the reconciler hits when parsing a
911    /// CRD `enum:`-validated value back to the typed policy.
912    #[test]
913    fn teardown_policy_is_well_formed_closed_set() {
914        tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
915    }
916
917    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
918    /// output verbatim for every variant. A future variant rename
919    /// (or an `as_str` arm typo) lands here at one site. The reason
920    /// string `lifetime_clock::evaluate` stamps reaches for the same
921    /// projection via `Display`, so a Debug-vs-canonical drift would
922    /// surface here, not in operator-facing reason strings.
923    #[test]
924    fn teardown_policy_as_str_matches_serde() {
925        crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
926    }
927
928    /// The Display impl IS `as_str` — pinning this lets future
929    /// callers (notably `lifetime_clock::evaluate`'s reason string)
930    /// reach for either projection without drift.
931    #[test]
932    fn teardown_policy_display_matches_as_str() {
933        crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
934    }
935
936    /// `FromStr` rejects strings that aren't in the canonical
937    /// projection — lowercased / typo / unrelated — and the error
938    /// echoes the input verbatim so the operator-facing diagnostic
939    /// carries the offending value, not a normalized form. The
940    /// empty-input arm is pinned by
941    /// [`teardown_policy_is_well_formed_closed_set`] via the
942    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
943    /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
944    /// newtype, which the trait's `make_unknown` can't see.
945    #[test]
946    fn unknown_teardown_policy_errors() {
947        use std::str::FromStr;
948        for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
949            let err = TeardownPolicy::from_str(bad).unwrap_err();
950            assert_eq!(err.0, bad, "error payload should echo input verbatim");
951        }
952    }
953
954    /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
955    /// the documented (policy, phase) → bool table for every variant
956    /// at every typed phase. The two terminal phases (Attested,
957    /// Failed) carry the policy-specific result; every non-terminal
958    /// phase returns `false`. The closed-set sweep over both
959    /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
960    /// variant in either enum reaches this test by iteration — no
961    /// per-test array maintenance.
962    #[test]
963    fn teardown_policy_should_teardown_on_truth_table() {
964        for policy in TeardownPolicy::ALL {
965            for phase in ProcessPhase::ALL {
966                let expected = match phase {
967                    ProcessPhase::Attested => {
968                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
969                    }
970                    ProcessPhase::Failed => {
971                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
972                    }
973                    _ => false,
974                };
975                assert_eq!(
976                    policy.should_teardown_on(phase),
977                    expected,
978                    "should_teardown_on({policy:?}, {phase:?}) drift",
979                );
980            }
981        }
982    }
983
984    /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
985    /// `should_teardown_on_failed` predicates agree with the typed
986    /// `should_teardown_on(phase)` dispatch they delegate to, for
987    /// every variant. A regression that re-introduces an inline
988    /// `matches!` in either legacy predicate fails here the moment
989    /// `should_teardown_on` is the source of truth.
990    #[test]
991    fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
992        for policy in TeardownPolicy::ALL {
993            assert_eq!(
994                policy.should_teardown_on_attested(),
995                policy.should_teardown_on(ProcessPhase::Attested),
996                "Attested delegate drift for {policy:?}",
997            );
998            assert_eq!(
999                policy.should_teardown_on_failed(),
1000                policy.should_teardown_on(ProcessPhase::Failed),
1001                "Failed delegate drift for {policy:?}",
1002            );
1003        }
1004    }
1005
1006    #[test]
1007    fn serde_round_trip_ephemeral() {
1008        // Routes through the ONE substrate composer
1009        // [`Lifetime::ephemeral`] — see the composer's doc-comment for
1010        // the full migration rationale.
1011        let l = Lifetime::ephemeral(EphemeralLifetime {
1012            ttl: "30m".into(),
1013            teardown_policy: TeardownPolicy::OnAttested,
1014            max_concurrent: 4,
1015            exports: vec![],
1016        });
1017        let yaml = serde_yaml::to_string(&l).unwrap();
1018        assert!(yaml.contains("ttl: 30m"));
1019        assert!(yaml.contains("teardownPolicy: OnAttested"));
1020        // Empty exports skip-serialize — explicit zero-trace default.
1021        assert!(!yaml.contains("exports"));
1022        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
1023        assert!(back.is_ephemeral());
1024        assert!(back.ephemeral.unwrap().exports.is_empty());
1025    }
1026
1027    #[test]
1028    fn applicable_exports_filters_by_trigger() {
1029        use crate::export::{
1030            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
1031            VectorChannel,
1032        };
1033        let spec_attested = ExportSpec {
1034            source: ArtifactSource {
1035                receipts: Some(ReceiptsSource::default()),
1036                ..ArtifactSource::default()
1037            },
1038            channel: VectorChannel {
1039                http_event: Some(HttpEventChannel::signal("receipt")),
1040                ..VectorChannel::default()
1041            },
1042            when: ExportTrigger::OnAttested,
1043            experiment_id_override: None,
1044        };
1045        let spec_failed = ExportSpec {
1046            when: ExportTrigger::OnFailed,
1047            ..spec_attested.clone()
1048        };
1049        let spec_always = ExportSpec {
1050            when: ExportTrigger::Always,
1051            ..spec_attested.clone()
1052        };
1053
1054        let lt = EphemeralLifetime {
1055            ttl: "1h".into(),
1056            teardown_policy: TeardownPolicy::OnAttested,
1057            max_concurrent: 1,
1058            exports: vec![spec_attested, spec_failed, spec_always],
1059        };
1060
1061        // Attested gate fires OnAttested + Always — 2 of 3.
1062        assert!(lt.has_applicable_exports(ProcessPhase::Attested));
1063        assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
1064
1065        // Failed gate fires OnFailed + Always — 2 of 3.
1066        assert!(lt.has_applicable_exports(ProcessPhase::Failed));
1067        assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
1068
1069        // Other phases never route through Releasing.
1070        for p in [
1071            ProcessPhase::Pending,
1072            ProcessPhase::Forking,
1073            ProcessPhase::Execing,
1074            ProcessPhase::Running,
1075            ProcessPhase::Reconverging,
1076            ProcessPhase::Releasing,
1077            ProcessPhase::Exiting,
1078            ProcessPhase::Zombie,
1079            ProcessPhase::Reaped,
1080        ] {
1081            assert!(!lt.has_applicable_exports(p));
1082            assert_eq!(lt.applicable_exports(p).count(), 0);
1083        }
1084    }
1085
1086    #[test]
1087    fn no_exports_means_no_applicable_exports() {
1088        let lt = EphemeralLifetime::default();
1089        assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
1090        assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
1091    }
1092
1093    /// Structural well-formedness of [`LifetimeKind`] as a
1094    /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-
1095    /// wide testkit that pins ALL structural invariants (`ALL` is
1096    /// non-empty, every variant round-trips through `label ↔
1097    /// parse_label`, labels are pairwise distinct, `""` is outside
1098    /// the closed set, the [`UnknownLifetimeKind`] carrier's Display
1099    /// renders the substrate-wide `"unknown lifetime kind: <input>"`
1100    /// shape, `labels()` equals the natural `ALL × label` projection)
1101    /// at ONE call site. Subsumes the hand-derived
1102    /// `lifetime_kind_all_is_unique_and_complete` sweep the pre-derive
1103    /// site published — clauses (1)+(3) of the testkit fold uniqueness
1104    /// + non-emptiness into the substrate primitive's own body.
1105    #[test]
1106    fn lifetime_kind_is_well_formed_closed_set() {
1107        tatara_closed_set::assert_closed_set_well_formed::<LifetimeKind>();
1108    }
1109
1110    /// The Display impl IS `as_str` — pinning this lets future callers
1111    /// reach for either projection without drift. Symmetric to every
1112    /// sibling `X_display_matches_as_str` invariant across
1113    /// `tatara-process`; routes through the substrate primitive
1114    /// [`crate::tagged_union::assert_display_matches_label`] shared
1115    /// with all 29+ production Display-alignment sites. The auto-
1116    /// derived `Display` body from `#[closed_set(via = "as_str",
1117    /// display)]` emits the substrate-wide `f.write_str(Self::as_str
1118    /// (*self))` shape — a regression that regresses `as_str` (or a
1119    /// future hand-rolled Display block that drifts from `as_str`)
1120    /// surfaces here at the substrate-wide alignment probe.
1121    #[test]
1122    fn lifetime_kind_display_matches_as_str() {
1123        crate::tagged_union::assert_display_matches_label::<LifetimeKind>();
1124    }
1125
1126    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
1127    /// camelCase serde field name on `Lifetime`. A future rename of
1128    /// any field lands here at one site — and the wire-key alignment
1129    /// stays coherent with the operator-facing serde shape.
1130    ///
1131    /// Routes through the substrate primitive
1132    /// [`crate::tagged_union::assert_wire_key_matches_label`] — the
1133    /// bound-relaxed peer of `assert_single_slot_key_matches_label`
1134    /// that drops the `T: TaggedUnion` requirement so `Lifetime`
1135    /// (whose empty variant resolves to `Permanent(&DEFAULT_PERMANENT)`
1136    /// rather than to a [`crate::tagged_union::TaggedUnionError::empty`]
1137    /// carrier) still binds through ONE substrate wire-key alignment
1138    /// site. Pre-lift the body restated the same serialize +
1139    /// exactly-one-key + name-equality sweep at this test surface
1140    /// verbatim; post-lift the projection lives at ONE substrate
1141    /// primitive and this site binds through a single call — the
1142    /// same mechanical shape the four sibling TaggedUnion parents
1143    /// carry via the trait-projected [`crate::tagged_union::assert_single_slot_key_matches_label`].
1144    #[test]
1145    fn lifetime_kind_as_str_matches_lifetime_field_name() {
1146        crate::tagged_union::assert_wire_key_matches_label::<Lifetime, LifetimeKind, _>(
1147            single_slot_lifetime,
1148        );
1149    }
1150
1151    /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
1152    /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
1153    /// projection composes the closed set in both directions — a
1154    /// regression that misroutes a select arm (e.g. `Self::Permanent =>
1155    /// l.ephemeral.as_ref()...`) fails loudly here.
1156    #[test]
1157    fn lifetime_kind_round_trips_through_variant_kind() {
1158        for kind in LifetimeKind::ALL {
1159            let l = single_slot_lifetime(kind);
1160            let v = kind.select(&l).expect("populated slot must select");
1161            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
1162            // And the resolver lands on the same variant.
1163            assert_eq!(
1164                l.variant().expect("exactly-one variant").kind(),
1165                kind,
1166                "variant() resolver disagreed on {kind:?}"
1167            );
1168        }
1169    }
1170
1171    // ─── Lifetime::has substrate pins ────────────────────────────────
1172    //
1173    // Fail-before-pass-after granularity: the `Lifetime::has` inherent
1174    // method did not exist before this commit, so each test below
1175    // fails to compile pre-lift. Post-lift they collectively pin the
1176    // (POPULATED slot × closed-set discriminator) presence-probe shape
1177    // at ONE substrate primitive on `Lifetime` — a regression that
1178    // drifted `has` from `kind.select(self).is_some()` (e.g. a swap to
1179    // "resolver picks kind" semantics that would silently promote
1180    // `Lifetime::default().has(Permanent)` from `false` to `true`)
1181    // surfaces HERE rather than as caller-side skew across the
1182    // `is_ephemeral` delegator + every future `lifetime-<kind>`
1183    // requires-tag consumer.
1184
1185    /// POPULATED-slot semantics pin: `has(kind)` returns `true` iff
1186    /// the slot addressed by `kind` is `Some(_)`. Sweeps every
1187    /// [`LifetimeKind::ALL`] entry against a single-slot fixture on
1188    /// the diagonal (populated slot AND matching kind → `true`) and
1189    /// off the diagonal (populated slot BUT other kind → `false`).
1190    /// Byte-shape parity with the pre-lift `self.<field>.is_some()`
1191    /// probe [`Lifetime::is_ephemeral`] walked and with the sibling
1192    /// [`crate::intent::Intent::has`] shape on `ProcessSpec`.
1193    #[test]
1194    fn lifetime_has_returns_true_on_diagonal_and_false_off_diagonal() {
1195        for populated in LifetimeKind::ALL {
1196            let l = single_slot_lifetime(populated);
1197            for probed in LifetimeKind::ALL {
1198                let expected = probed == populated;
1199                assert_eq!(
1200                    l.has(probed),
1201                    expected,
1202                    "Lifetime::has drift — populated={populated:?} probed={probed:?} expected={expected}",
1203                );
1204            }
1205        }
1206    }
1207
1208    /// SUBSTRATE-DELEGATION pin: `has(kind)` matches
1209    /// `kind.select(self).is_some()` byte-for-byte across every
1210    /// [`LifetimeKind::ALL`] entry and every diagonal / off-diagonal
1211    /// input. A regression that specialized `has` (a hand-rolled
1212    /// per-variant match block that drifts from the closed-set-driven
1213    /// `select` dispatcher, an early-return that short-circuits ambiguity
1214    /// checks a future `Lifetime::variant`-resolver refinement would
1215    /// need) surfaces HERE rather than as silent per-consumer drift.
1216    #[test]
1217    fn lifetime_has_matches_kind_select_is_some_bytewise() {
1218        for populated in LifetimeKind::ALL {
1219            let l = single_slot_lifetime(populated);
1220            for probed in LifetimeKind::ALL {
1221                assert_eq!(
1222                    l.has(probed),
1223                    probed.select(&l).is_some(),
1224                    "Lifetime::has drifted from kind.select(self).is_some() for populated={populated:?} probed={probed:?}",
1225                );
1226            }
1227        }
1228    }
1229
1230    /// EMPTY-lifetime pin: a [`Lifetime`] with both slots [`None`]
1231    /// returns `false` for EVERY [`LifetimeKind`] — even though
1232    /// [`Lifetime::variant`] would resolve it to `Ok(Permanent)` via
1233    /// the default fallback. The two probes answer distinct questions
1234    /// (POPULATED slot vs RESOLVED variant); the pin binds the
1235    /// POPULATED semantic so a future consumer that reaches for
1236    /// `has(Permanent)` on a default lifetime hits the operator-visible
1237    /// "no permanent slot stamped" answer rather than the resolver's
1238    /// "empty defaults to Permanent" answer.
1239    #[test]
1240    fn lifetime_has_returns_false_on_default_lifetime_for_every_kind() {
1241        let l = Lifetime::default();
1242        for kind in LifetimeKind::ALL {
1243            assert!(
1244                !l.has(kind),
1245                "default Lifetime has no slot populated, yet has({kind:?}) returned true",
1246            );
1247        }
1248        // Sanity: the resolver still picks Permanent on the empty
1249        // input. If this changed, the semantics on the two probes
1250        // would diverge and the pin above would need re-thinking.
1251        assert_eq!(
1252            l.variant().expect("default resolves").kind(),
1253            LifetimeKind::Permanent,
1254        );
1255    }
1256
1257    /// AMBIGUOUS-lifetime pin: a [`Lifetime`] with BOTH slots
1258    /// [`Some`] returns `true` for EVERY [`LifetimeKind`] — the
1259    /// POPULATED probe answers per-slot independently and does NOT
1260    /// short-circuit through the resolver's ambiguity error. The two
1261    /// probes answer distinct questions (POPULATED slot vs RESOLVED
1262    /// variant); [`Lifetime::variant`] on the same input errors with
1263    /// [`LifetimeError::Ambiguous`], while `has` reports both slots
1264    /// stamped. A future consumer that wants "did the operator stamp
1265    /// this slot" (an audit binary flagging both-slot Processes for
1266    /// migration) reaches through `has`; a consumer that wants "did
1267    /// the resolver settle on this kind" composes through `variant`.
1268    #[test]
1269    fn lifetime_has_returns_true_on_ambiguous_lifetime_for_every_populated_kind() {
1270        let l = Lifetime {
1271            permanent: Some(PermanentLifetime {}),
1272            ephemeral: Some(EphemeralLifetime::default()),
1273        };
1274        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1275        for kind in LifetimeKind::ALL {
1276            assert!(
1277                l.has(kind),
1278                "ambiguous Lifetime has both slots populated, yet has({kind:?}) returned false",
1279            );
1280        }
1281    }
1282
1283    /// DELEGATION pin: [`Lifetime::is_ephemeral`] delegates through
1284    /// [`Lifetime::has`]`(LifetimeKind::Ephemeral)` byte-for-byte across
1285    /// every representative input (empty, permanent-only, ephemeral-
1286    /// only, ambiguous). A regression that reintroduces the pre-lift
1287    /// `self.ephemeral.is_some()` inline body (breaking the delegation
1288    /// chain to the substrate primitive) would silently succeed
1289    /// bytewise TODAY — but would strand [`Lifetime::is_ephemeral`]
1290    /// out of every future normalization landing at [`Self::has`]
1291    /// (a widened return, a debug-build assertion, a per-fleet warn).
1292    /// This pin binds the delegation so the divergence surfaces HERE
1293    /// rather than as silent drift downstream.
1294    #[test]
1295    fn is_ephemeral_delegates_through_has_ephemeral_bytewise() {
1296        let inputs: [Lifetime; 4] = [
1297            Lifetime::default(),
1298            Lifetime::permanent(),
1299            Lifetime::ephemeral(EphemeralLifetime::default()),
1300            Lifetime {
1301                permanent: Some(PermanentLifetime {}),
1302                ephemeral: Some(EphemeralLifetime::default()),
1303            },
1304        ];
1305        for l in &inputs {
1306            assert_eq!(
1307                l.is_ephemeral(),
1308                l.has(LifetimeKind::Ephemeral),
1309                "is_ephemeral() drifted from has(Ephemeral) for {l:?}",
1310            );
1311        }
1312    }
1313
1314    /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
1315    /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
1316    /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
1317    /// previously hand-rolled.
1318    #[test]
1319    fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
1320        let permanent = PermanentLifetime {};
1321        let v = LifetimeVariant::Permanent(&permanent);
1322        assert!(v.as_ephemeral().is_none());
1323        assert!(v.as_permanent().is_some());
1324
1325        let ephemeral = EphemeralLifetime {
1326            ttl: "42m".into(),
1327            teardown_policy: TeardownPolicy::OnAttested,
1328            max_concurrent: 3,
1329            exports: vec![],
1330        };
1331        let v = LifetimeVariant::Ephemeral(&ephemeral);
1332        let inner = v.as_ephemeral().expect("ephemeral must project");
1333        assert_eq!(inner.ttl, "42m");
1334        assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
1335        assert_eq!(inner.max_concurrent, 3);
1336        assert!(v.as_permanent().is_none());
1337    }
1338
1339    /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
1340    /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
1341    /// iff the resolver picks the ephemeral slot unambiguously. All
1342    /// three failure modes (empty → permanent default, permanent-only,
1343    /// ambiguous) collapse to `None`, matching the pre-lift
1344    /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
1345    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
1346    ///
1347    /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
1348    /// permanent-only: an operator-authored spec with both slots
1349    /// populated is a mis-configuration, and firing TTL / teardown on
1350    /// it would be worse than skipping. Pinning that collapse here
1351    /// closes the possibility of a future per-consumer drift where one
1352    /// branch honors ambiguity (fires the timed action) and another
1353    /// doesn't.
1354    ///
1355    /// The `Some` arm asserts byte-identity of the projected borrow
1356    /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
1357    /// silently swapped the projection to `self.permanent.as_ref()`
1358    /// would surface here as a type mismatch rather than as a runtime
1359    /// no-op in production.
1360    #[test]
1361    fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
1362        // 1. Empty (both slots None) — resolves to Permanent default.
1363        let l = Lifetime::default();
1364        assert!(l.resolved_ephemeral().is_none());
1365
1366        // 2. Permanent-only.
1367        // Routes through the ONE substrate composer
1368        // [`Lifetime::permanent`] — see the composer's doc-comment.
1369        let l = Lifetime::permanent();
1370        assert!(l.resolved_ephemeral().is_none());
1371
1372        // 3. Ephemeral-only — the ONE arm that projects.
1373        let ephemeral = EphemeralLifetime {
1374            ttl: "13m".into(),
1375            teardown_policy: TeardownPolicy::OnFailed,
1376            max_concurrent: 7,
1377            exports: vec![],
1378        };
1379        // Routes through the ONE substrate composer
1380        // [`Lifetime::ephemeral`] — see the composer's doc-comment.
1381        let l = Lifetime::ephemeral(ephemeral.clone());
1382        let e = l.resolved_ephemeral().expect("ephemeral-only must project");
1383        assert_eq!(e.ttl, "13m");
1384        assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
1385        assert_eq!(e.max_concurrent, 7);
1386        // The borrow points into `self.ephemeral`, not into a temporary.
1387        assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
1388
1389        // 4. Ambiguous (both slots set) — collapses to None, NOT to
1390        //    the ephemeral inner. Guards against a future refactor
1391        //    that silently unwrapped ambiguity to "prefer ephemeral".
1392        let l = Lifetime {
1393            permanent: Some(PermanentLifetime {}),
1394            ephemeral: Some(EphemeralLifetime::default()),
1395        };
1396        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1397        assert!(l.resolved_ephemeral().is_none());
1398    }
1399
1400    /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
1401    /// set" outcome is `Permanent`, not an error. Pin via the
1402    /// closed-set kind projection so a future variant added to the
1403    /// closed set (and to the `Lifetime` struct) without updating
1404    /// the default resolution would surface here — the default
1405    /// stays `Permanent` regardless of the closed set's arity.
1406    #[test]
1407    fn empty_lifetime_resolves_to_permanent_kind() {
1408        let l = Lifetime::default();
1409        let v = l.variant().expect("default lifetime resolves");
1410        assert_eq!(v.kind(), LifetimeKind::Permanent);
1411        assert!(v.as_permanent().is_some());
1412        assert!(v.as_ephemeral().is_none());
1413    }
1414
1415    /// Construct a `Lifetime` with exactly the given kind's slot
1416    /// populated by a minimal valid inner spec. Shared across the
1417    /// closed-set property tests so they each cover every variant
1418    /// without restating the construction table.
1419    fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
1420        // Each arm routes through the ONE substrate composer for its
1421        // closed-set discriminator — [`Lifetime::permanent`] /
1422        // [`Lifetime::ephemeral`]. See their doc-comments for the
1423        // migration rationale.
1424        match kind {
1425            LifetimeKind::Permanent => Lifetime::permanent(),
1426            LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
1427        }
1428    }
1429
1430    #[test]
1431    fn exports_round_trip_through_lifetime() {
1432        use crate::export::{
1433            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
1434            VectorChannel,
1435        };
1436        // Routes through the ONE substrate composer
1437        // [`Lifetime::ephemeral`] — see the composer's doc-comment.
1438        let l = Lifetime::ephemeral(EphemeralLifetime {
1439            ttl: "30m".into(),
1440            teardown_policy: TeardownPolicy::OnAttested,
1441            max_concurrent: 1,
1442            exports: vec![ExportSpec {
1443                source: ArtifactSource {
1444                    receipts: Some(ReceiptsSource::default()),
1445                    ..ArtifactSource::default()
1446                },
1447                channel: VectorChannel {
1448                    http_event: Some(HttpEventChannel::signal("receipt")),
1449                    ..VectorChannel::default()
1450                },
1451                when: ExportTrigger::OnAttested,
1452                experiment_id_override: None,
1453            }],
1454        });
1455        let yaml = serde_yaml::to_string(&l).unwrap();
1456        assert!(yaml.contains("exports:"));
1457        assert!(yaml.contains("receipts: {}"));
1458        assert!(yaml.contains("signalType: receipt"));
1459        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
1460        let e = back.ephemeral.unwrap();
1461        assert_eq!(e.exports.len(), 1);
1462        assert!(e.exports[0].source.receipts.is_some());
1463        assert!(e.exports[0].channel.http_event.is_some());
1464    }
1465
1466    // ─── EphemeralLifetime::ttl_duration substrate pins ──────────────
1467    //
1468    // The `humantime::parse_duration(&<eph>.ttl).ok()` chain was open-
1469    // lifted from TWO consumer sites in `crate::lifetime_clock`
1470    // (`evaluate` + `requeue_with_ttl`) onto the ONE substrate
1471    // primitive [`EphemeralLifetime::ttl_duration`]. These pins bind
1472    // the primitive at the fail-before-pass-after level so a future
1473    // regression that swaps the return-form (an
1474    // `anyhow::Result<Duration>` — a per-consumer normalization gate
1475    // — a saturating `Duration::ZERO` for the parse-error corner)
1476    // fails HERE before landing at either consumer.
1477
1478    fn eph_with_ttl(ttl: &str) -> EphemeralLifetime {
1479        EphemeralLifetime {
1480            ttl: ttl.to_string(),
1481            teardown_policy: TeardownPolicy::default(),
1482            max_concurrent: 1,
1483            exports: Vec::new(),
1484        }
1485    }
1486
1487    /// The canonical shape every consumer rides through — a parseable
1488    /// humantime string projects to `Some(std::time::Duration)` matching
1489    /// the operator-authored `ttl` verbatim. Pin: the returned duration
1490    /// is EXACTLY what `humantime::parse_duration` produces for the
1491    /// same input, in `std::time::Duration` so the downstream
1492    /// `elapsed >= ttl` / `ttl.checked_sub(elapsed)` comparators land
1493    /// with both operands on the same axis without a per-consumer
1494    /// conversion.
1495    #[test]
1496    fn ttl_duration_parseable_humantime_projects_to_some() {
1497        for (ttl, expected) in [
1498            ("1h", std::time::Duration::from_secs(3600)),
1499            ("30m", std::time::Duration::from_secs(1800)),
1500            ("90s", std::time::Duration::from_secs(90)),
1501            ("5m30s", std::time::Duration::from_secs(330)),
1502            ("500ms", std::time::Duration::from_millis(500)),
1503        ] {
1504            assert_eq!(
1505                eph_with_ttl(ttl).ttl_duration(),
1506                Some(expected),
1507                "ttl_duration drift for {ttl:?}",
1508            );
1509        }
1510    }
1511
1512    /// The `None` arm is the "operator's ttl string doesn't parse"
1513    /// corner every consumer collapses to the skip-branch — a typo, an
1514    /// unsupported unit, an empty string, a free-form label that
1515    /// reached the field. Pin the boundary at the primitive so a
1516    /// future normalization can't silently substitute a default in
1517    /// place of the parse-failure signal.
1518    #[test]
1519    fn ttl_duration_unparseable_returns_none() {
1520        for bad in [
1521            // Empty string — the operator left the ttl blank.
1522            "", // Non-humantime literal — the operator wrote a foreign format.
1523            "forever", "1our",
1524            // Nonsense that looks numeric but isn't a humantime span.
1525            "abc",
1526            // Only-whitespace input — passes serde's non-empty gate but
1527            // doesn't parse as a duration.
1528            "   ",
1529        ] {
1530            assert_eq!(
1531                eph_with_ttl(bad).ttl_duration(),
1532                None,
1533                "ttl_duration should be None for {bad:?}",
1534            );
1535        }
1536    }
1537
1538    /// Zero-duration edge — `"0s"` parses to `Duration::ZERO`, not
1539    /// `None`. Every consumer needs the zero-ttl EphemeralLifetime to
1540    /// count as "elapsed=0 already ≥ ttl=0" so a zero-TTL ephemeral
1541    /// expires on its own creation instant; swapping this arm to
1542    /// `None` would silently keep every zero-TTL Process alive past
1543    /// the TTL-expiry gate in [`crate::lifetime_clock::evaluate`].
1544    #[test]
1545    fn ttl_duration_zero_seconds_returns_some_zero() {
1546        assert_eq!(
1547            eph_with_ttl("0s").ttl_duration(),
1548            Some(std::time::Duration::ZERO),
1549        );
1550    }
1551
1552    /// Subsecond precision survives the parse — a regression that
1553    /// silently truncated to whole seconds would compare
1554    /// `elapsed = 500ms` against a `ttl_duration()` of `500ms` as
1555    /// `500ms >= 0s` (always fire) rather than `500ms >= 500ms`
1556    /// (fires on the boundary). Peer to the sibling substrate
1557    /// `elapsed_since` subsecond pin in `crate::time`.
1558    #[test]
1559    fn ttl_duration_preserves_subsecond_precision() {
1560        assert_eq!(
1561            eph_with_ttl("250ms").ttl_duration(),
1562            Some(std::time::Duration::from_millis(250)),
1563        );
1564    }
1565
1566    /// Default `EphemeralLifetime` carries the canonical `"1h"` ttl
1567    /// (matches `default_ttl()` at this file's top), so
1568    /// `.ttl_duration()` on it agrees with a manually-parsed `"1h"`
1569    /// pass through `humantime`. Pins the default-ttl contract at
1570    /// the substrate so a future default rename lands at ONE site
1571    /// (this ttl_duration pin + the `default_ttl` fn) without silent
1572    /// wall-clock drift at either consumer.
1573    #[test]
1574    fn ttl_duration_of_default_ephemeral_matches_1h() {
1575        let e = EphemeralLifetime::default();
1576        assert_eq!(e.ttl, "1h");
1577        assert_eq!(e.ttl_duration(), Some(std::time::Duration::from_secs(3600)));
1578    }
1579
1580    /// Byte-for-byte parity with the pre-lift hand-authored chain —
1581    /// `<eph>.ttl_duration()` produces the SAME
1582    /// `Option<std::time::Duration>` as the two-link `humantime::
1583    /// parse_duration(&<eph>.ttl).ok()` chain both `lifetime_clock`
1584    /// consumers walked pre-lift. A regression at THIS pin fails
1585    /// before it lands at either consumer as silent operator-facing
1586    /// skew between the TTL-expiry gate and the sleep-budget picker.
1587    #[test]
1588    fn ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
1589        for ttl in [
1590            "1h", "30m", "90s", "5m30s", "500ms", "0s", "1us", "forever", "", "1our",
1591        ] {
1592            let e = eph_with_ttl(ttl);
1593            let via_primitive = e.ttl_duration();
1594            let hand_authored = humantime::parse_duration(&e.ttl).ok();
1595            assert_eq!(
1596                via_primitive, hand_authored,
1597                "ttl_duration must be byte-identical to `humantime::\
1598                 parse_duration(&self.ttl).ok()` for {ttl:?}",
1599            );
1600        }
1601    }
1602
1603    // ─── Lifetime::{permanent,ephemeral} substrate composer pins ─────
1604    //
1605    // The `Lifetime { permanent: Some(PermanentLifetime {}), .. }` +
1606    // `Lifetime { ephemeral: Some(<e>), .. }` shapes were open-lifted
1607    // from FOUR + ELEVEN+ hand-authored fixture literals onto the ONE
1608    // substrate composer pair [`Lifetime::permanent`] +
1609    // [`Lifetime::ephemeral`]. These pins bind the composers at the
1610    // fail-before-pass-after level so a future regression that flipped
1611    // either arm (a swapped slot assignment, a stray `Some` on the
1612    // opposite slot, a drift in the resolver's landing variant) fails
1613    // HERE before landing at any of the fifteen+ consumer sites.
1614
1615    /// Pre-lift the `Lifetime { permanent: Some(PermanentLifetime {}),
1616    /// ephemeral: None }` (equivalently `Lifetime { permanent:
1617    /// Some(PermanentLifetime {}), ..Lifetime::default() }`) shape had
1618    /// three surfaces every consumer paired: the resolver picks
1619    /// `Permanent`, the `is_ephemeral` gate reads `false`, and the
1620    /// two slots read as (`Some`, `None`). Bind all three from the
1621    /// composer in ONE assertion group.
1622    #[test]
1623    fn lifetime_permanent_composer_matches_pre_lift_shape_bytewise() {
1624        let via_primitive = Lifetime::permanent();
1625        let hand_authored = Lifetime {
1626            permanent: Some(PermanentLifetime {}),
1627            ephemeral: None,
1628        };
1629
1630        // Both slots read identically.
1631        assert!(via_primitive.permanent.is_some());
1632        assert!(hand_authored.permanent.is_some());
1633        assert!(via_primitive.ephemeral.is_none());
1634        assert!(hand_authored.ephemeral.is_none());
1635
1636        // is_default is false (permanent is set), is_ephemeral is false.
1637        assert!(!via_primitive.is_default());
1638        assert!(!via_primitive.is_ephemeral());
1639        assert_eq!(via_primitive.is_default(), hand_authored.is_default());
1640        assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
1641
1642        // Resolver lands on `Permanent`, not on `Ambiguous`.
1643        assert_eq!(
1644            via_primitive
1645                .variant()
1646                .expect("permanent-only resolves")
1647                .kind(),
1648            LifetimeKind::Permanent,
1649        );
1650
1651        // resolved_ephemeral projects to None (peer arm of the
1652        // ephemeral-only composer's Some(&e) landing).
1653        assert!(via_primitive.resolved_ephemeral().is_none());
1654    }
1655
1656    /// Pre-lift the `Lifetime { permanent: None, ephemeral: Some(<e>) }`
1657    /// (equivalently `Lifetime { ephemeral: Some(<e>), ..Lifetime::
1658    /// default() }`) shape had four surfaces every consumer paired: the
1659    /// resolver picks `Ephemeral(<e>)`, the `is_ephemeral` gate reads
1660    /// `true`, the two slots read as (`None`, `Some`), and
1661    /// [`Lifetime::resolved_ephemeral`] projects to `Some(&<e>)` with
1662    /// the SAME inner spec bytes the caller supplied. Bind all four
1663    /// from the composer in ONE assertion group.
1664    #[test]
1665    fn lifetime_ephemeral_composer_matches_pre_lift_shape_bytewise() {
1666        let inner = EphemeralLifetime {
1667            ttl: "17m".into(),
1668            teardown_policy: TeardownPolicy::OnFailed,
1669            max_concurrent: 5,
1670            exports: vec![],
1671        };
1672        let via_primitive = Lifetime::ephemeral(inner.clone());
1673        let hand_authored = Lifetime {
1674            permanent: None,
1675            ephemeral: Some(inner.clone()),
1676        };
1677
1678        // Both slots read identically.
1679        assert!(via_primitive.permanent.is_none());
1680        assert!(hand_authored.permanent.is_none());
1681        assert!(via_primitive.ephemeral.is_some());
1682        assert!(hand_authored.ephemeral.is_some());
1683
1684        // is_default is false, is_ephemeral is true.
1685        assert!(!via_primitive.is_default());
1686        assert!(via_primitive.is_ephemeral());
1687        assert_eq!(via_primitive.is_default(), hand_authored.is_default());
1688        assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
1689
1690        // Resolver lands on `Ephemeral` with the SAME inner bytes.
1691        let via_inner = via_primitive
1692            .resolved_ephemeral()
1693            .expect("ephemeral-only resolves to Some(&e)");
1694        assert_eq!(via_inner.ttl, inner.ttl);
1695        assert_eq!(via_inner.teardown_policy, inner.teardown_policy);
1696        assert_eq!(via_inner.max_concurrent, inner.max_concurrent);
1697
1698        // Kind projects to `Ephemeral`.
1699        assert_eq!(
1700            via_primitive
1701                .variant()
1702                .expect("ephemeral-only resolves")
1703                .kind(),
1704            LifetimeKind::Ephemeral,
1705        );
1706    }
1707
1708    /// The two composers PARTITION the closed set — every
1709    /// `LifetimeKind` variant is reachable by exactly ONE composer,
1710    /// and the composer's landing kind matches the discriminator. A
1711    /// future third variant added to `LifetimeKind` without a paired
1712    /// composer would surface here (the exhaustive `ALL` sweep would
1713    /// hit a case with no arm to construct through).
1714    #[test]
1715    fn lifetime_composers_cover_every_non_ambiguous_closed_set_arm() {
1716        for kind in LifetimeKind::ALL {
1717            let via_composer = match kind {
1718                LifetimeKind::Permanent => Lifetime::permanent(),
1719                LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
1720            };
1721            let resolved = via_composer
1722                .variant()
1723                .expect("composer output resolves unambiguously")
1724                .kind();
1725            assert_eq!(
1726                resolved, kind,
1727                "composer for {kind:?} must land on the SAME resolver kind",
1728            );
1729        }
1730    }
1731
1732    // ─── Workspace-canonical ephemeral-defaults substrate pins ────────
1733    //
1734    // Bind [`default_ephemeral_ttl`] + [`default_ephemeral_max_concurrent`]
1735    // and the paired [`DEFAULT_EPHEMERAL_TTL`] + [`DEFAULT_EPHEMERAL_MAX_CONCURRENT`]
1736    // consts at fail-before-pass-after granularity so a regression that
1737    // drifted the wire-form default (a shift from `"1h"` to `"30m"` at
1738    // ONLY the const, a shift from `1` to `2` at only the fn body, a
1739    // decoupling of the const from the fn's returned value) surfaces
1740    // HERE rather than as silent operator-visible skew across the
1741    // THREE serde-default consumers ([`EphemeralLifetime::ttl`] +
1742    // [`crate::ephemeral::EphemeralSpec::ttl`] + `tatara-reconciler
1743    // ::ephemeral_defaults::EphemeralDefaults::default_ttl`) that ride
1744    // through this ONE substrate owner.
1745
1746    #[test]
1747    fn default_ephemeral_ttl_matches_pre_lift_1h_string_bytewise() {
1748        // Byte-shape parity with the THREE hand-authored pre-lift shims
1749        // that each returned `"1h".to_string()`. A regression that
1750        // drifted the returned string (an accidental locale-specific
1751        // `"1 h"` spacing, a typo to `"1H"` that survives serde but
1752        // fails humantime parse, a shift to a whole-second `"3600s"`
1753        // canonicalization) fails HERE rather than at the three
1754        // downstream consumers.
1755        assert_eq!(
1756            default_ephemeral_ttl(),
1757            "1h",
1758            "default_ephemeral_ttl must return the pre-lift `\"1h\"` \
1759             string bytewise; a regression that drifted the wire-form \
1760             surfaces here rather than as three-way skew across the \
1761             EphemeralLifetime / EphemeralSpec / EphemeralDefaults \
1762             consumers.",
1763        );
1764    }
1765
1766    #[test]
1767    fn default_ephemeral_ttl_wire_form_const_matches_fn_return_bytewise() {
1768        // Cross-form coherence pin: the `pub const DEFAULT_EPHEMERAL_TTL:
1769        // &str` handle and the `pub fn default_ephemeral_ttl() -> String`
1770        // owner MUST project onto the SAME wire-form value. A regression
1771        // that updated one but not the other (e.g. lifted the const to
1772        // a new default but forgot the fn body, or vice versa) would
1773        // silently produce two divergent workspace-canonical defaults
1774        // — the const for compile-time consumers, the fn for serde-
1775        // default consumers. Pin the two projections at equality.
1776        assert_eq!(
1777            DEFAULT_EPHEMERAL_TTL, "1h",
1778            "DEFAULT_EPHEMERAL_TTL const must byte-match the pre-lift \
1779             wire-form default `\"1h\"`",
1780        );
1781        assert_eq!(
1782            default_ephemeral_ttl(),
1783            DEFAULT_EPHEMERAL_TTL,
1784            "default_ephemeral_ttl() must byte-match the paired \
1785             DEFAULT_EPHEMERAL_TTL const — a divergence would silently \
1786             skew serde-default consumers vs compile-time const readers",
1787        );
1788    }
1789
1790    #[test]
1791    fn default_ephemeral_ttl_composes_at_ephemeral_lifetime_default_field() {
1792        // End-to-end: the `EphemeralLifetime::default()` composer routes
1793        // its `ttl:` slot through the substrate owner and the resulting
1794        // field reads bytewise-identical to the substrate primitive's
1795        // return. A regression that reintroduced a hand-authored `"1h"
1796        // .to_string()` inline at `Default::default` (or drifted the
1797        // ttl slot away from the substrate) would fail here.
1798        assert_eq!(
1799            EphemeralLifetime::default().ttl,
1800            default_ephemeral_ttl(),
1801            "EphemeralLifetime::default().ttl must route through \
1802             default_ephemeral_ttl — inline `\"1h\".to_string()` reintroduction \
1803             surfaces here rather than as skew with the two peer \
1804             ephemeral-defaults consumers",
1805        );
1806    }
1807
1808    #[test]
1809    fn default_ephemeral_ttl_composes_at_ephemeral_spec_serde_default() {
1810        // The `(defephemeral …)` Lisp authoring surface's serde default
1811        // must round-trip through the substrate owner: a YAML fragment
1812        // that omits the `ttl:` field parses into an EphemeralSpec whose
1813        // `ttl` reads bytewise-identical to the substrate primitive.
1814        // A regression that reintroduced a private `default_ttl` shim
1815        // in `ephemeral.rs` (bypassing the substrate) would produce a
1816        // silent skew between `defephemeral :ttl <omitted>` and
1817        // `EphemeralLifetime::default()`.
1818        let yaml = "\
1819aplicacao:
1820  chartRef: oci://ghcr.io/pleme-io/charts/lareira-demo-app
1821  version: \"0.5.5\"
1822  profile: all-in-one
1823  valuesOverlay: null
1824";
1825        let spec: crate::ephemeral::EphemeralSpec =
1826            serde_yaml::from_str(yaml).expect("EphemeralSpec YAML parses");
1827        assert_eq!(
1828            spec.ttl,
1829            default_ephemeral_ttl(),
1830            "EphemeralSpec serde-default for the omitted `ttl:` slot \
1831             must route through crate::lifetime::default_ephemeral_ttl \
1832             — a private-shim reintroduction skews the two peer \
1833             ephemeral-authoring surfaces silently",
1834        );
1835    }
1836
1837    #[test]
1838    fn default_ephemeral_ttl_parses_as_humantime_1h() {
1839        // Substrate invariant: the wire-form default MUST parse as a
1840        // humantime duration equal to one hour. A regression that
1841        // drifted the const to an unparseable string (a locale-specific
1842        // spelling, a serde-friendly-but-humantime-invalid literal)
1843        // would silently break every downstream TTL-expiry gate. Pin
1844        // the invariant at the substrate boundary rather than at every
1845        // consumer's own humantime-parse callsite.
1846        let parsed =
1847            humantime::parse_duration(DEFAULT_EPHEMERAL_TTL).expect("`\"1h\"` parses as humantime");
1848        assert_eq!(
1849            parsed,
1850            std::time::Duration::from_secs(3_600),
1851            "DEFAULT_EPHEMERAL_TTL must parse as a one-hour duration; a \
1852             regression that drifted the wire-form to an unparseable \
1853             string surfaces here rather than as silent TTL-expiry-gate \
1854             misfires at every downstream consumer",
1855        );
1856    }
1857
1858    #[test]
1859    fn default_ephemeral_max_concurrent_matches_pre_lift_1_bytewise() {
1860        // Byte-shape parity with the TWO hand-authored pre-lift shims
1861        // that each returned `1u32`. A regression that drifted the
1862        // returned value (a shift to `0` for uncapped, a shift to `2`
1863        // for parallel-safe defaults) surfaces here rather than at
1864        // both `EphemeralLifetime::max_concurrent` and
1865        // `EphemeralSpec::max_concurrent` serde defaults.
1866        assert_eq!(
1867            default_ephemeral_max_concurrent(),
1868            1,
1869            "default_ephemeral_max_concurrent must return the pre-lift \
1870             `1u32` value bytewise; a regression surfaces here rather \
1871             than as two-way skew across the EphemeralLifetime + \
1872             EphemeralSpec consumers.",
1873        );
1874    }
1875
1876    #[test]
1877    fn default_ephemeral_max_concurrent_wire_form_const_matches_fn_return_bytewise() {
1878        // Peer to the TTL cross-form pin — the `pub const
1879        // DEFAULT_EPHEMERAL_MAX_CONCURRENT: u32` handle and the
1880        // `pub fn default_ephemeral_max_concurrent() -> u32` owner MUST
1881        // project onto the SAME `1u32` value.
1882        assert_eq!(DEFAULT_EPHEMERAL_MAX_CONCURRENT, 1);
1883        assert_eq!(
1884            default_ephemeral_max_concurrent(),
1885            DEFAULT_EPHEMERAL_MAX_CONCURRENT,
1886        );
1887    }
1888
1889    #[test]
1890    fn default_ephemeral_max_concurrent_composes_at_ephemeral_lifetime_default_field() {
1891        // Same end-to-end contract as the TTL peer pin: the
1892        // `EphemeralLifetime::default()` composer routes its
1893        // `max_concurrent:` slot through the substrate owner and the
1894        // resulting field bytewise-matches the substrate primitive.
1895        assert_eq!(
1896            EphemeralLifetime::default().max_concurrent,
1897            default_ephemeral_max_concurrent(),
1898        );
1899    }
1900
1901    #[test]
1902    fn ephemeral_authoring_surfaces_share_workspace_canonical_ttl_default() {
1903        // Cross-surface coherence pin: the two `tatara-process`
1904        // ephemeral authoring surfaces — the lifetime-slot default and
1905        // the `(defephemeral …)` sugar default — MUST reach the SAME
1906        // workspace-canonical TTL string via serde default. A drift at
1907        // either site (a private-shim reintroduction, an inline literal
1908        // shortcut, a partial re-tuning that missed the peer) surfaces
1909        // HERE as observable skew between the two Default outputs.
1910        let yaml = "\
1911aplicacao:
1912  chartRef: oci://ghcr.io/pleme-io/charts/x
1913  version: \"0.1\"
1914  profile: p
1915  valuesOverlay: null
1916";
1917        let spec: crate::ephemeral::EphemeralSpec =
1918            serde_yaml::from_str(yaml).expect("EphemeralSpec parses");
1919        let lifetime = EphemeralLifetime::default();
1920        assert_eq!(
1921            spec.ttl, lifetime.ttl,
1922            "EphemeralSpec::ttl serde default and \
1923             EphemeralLifetime::default().ttl MUST agree — both must \
1924             route through crate::lifetime::default_ephemeral_ttl",
1925        );
1926        assert_eq!(
1927            spec.max_concurrent, lifetime.max_concurrent,
1928            "EphemeralSpec::max_concurrent serde default and \
1929             EphemeralLifetime::default().max_concurrent MUST agree — \
1930             both must route through crate::lifetime::default_ephemeral_max_concurrent",
1931        );
1932    }
1933
1934    /// Neither composer produces the ambiguous corner — the composer's
1935    /// contract is "exactly one slot set", and the ambiguous case
1936    /// [`LifetimeError::Ambiguous`] must be unreachable through them.
1937    /// A future refactor that widened either composer to accept the
1938    /// opposite slot (e.g. added a `permanent_with_ephemeral_override`
1939    /// arm) would surface here.
1940    #[test]
1941    fn lifetime_composers_never_produce_ambiguous_variant() {
1942        assert!(
1943            Lifetime::permanent().variant().is_ok(),
1944            "Lifetime::permanent must never resolve to Ambiguous",
1945        );
1946        assert!(
1947            Lifetime::ephemeral(EphemeralLifetime::default())
1948                .variant()
1949                .is_ok(),
1950            "Lifetime::ephemeral must never resolve to Ambiguous",
1951        );
1952    }
1953}