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