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, ExportSpecSliceExt};
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    /// Declared exports on this Lifetime — the `exports` vec on the
424    /// resolved [`EphemeralLifetime`] variant, or the empty slice
425    /// (`&[]`) when the resolver picks `Permanent`, the default (both
426    /// slots unset → `Permanent`), or the ambiguous corner (both slots
427    /// set → [`LifetimeError::Ambiguous`]).
428    ///
429    /// # Why lift
430    ///
431    /// The point-domain require-tag classifier in
432    /// `tatara-reconciler::bin::tatara-check` composed the SAME
433    /// two-step chain (`spec.lifetime.resolved_ephemeral().is_some_and(
434    /// |e| e.exports.<slice-primitive>(k))`) at SIX consecutive rows in
435    /// [`evaluate_point_require_tag`]'s prefix table
436    /// (`export-when-` → [`ExportSpecSliceExt::has_when`], `channel-`
437    /// → [`ExportSpecSliceExt::has_channel_kind`], `report-format-`
438    /// → [`ExportSpecSliceExt::has_report_format`], `artifact-`
439    /// → [`ExportSpecSliceExt::has_artifact_kind`],
440    /// `report-payload-shape-`
441    /// → [`ExportSpecSliceExt::has_report_payload_shape`],
442    /// `exports-fire-on-`
443    /// → [`ExportSpecSliceExt::has_applicable_at`]). Six restatements
444    /// of the ONE Option-carrier collapse past the ★★ PRIME-DIRECTIVE
445    /// ≥ 2 duplication threshold — post-lift ONE substrate primitive
446    /// owns the `(Lifetime → resolved ephemeral → exports slice OR
447    /// empty)` compound projection, and every current + future
448    /// slice-level probe on [`ExportSpec`] plugs into it through the
449    /// SAME [`ExportSpecSliceExt`] trait shape without a bespoke
450    /// Option-arm at the caller.
451    ///
452    /// # Semantics vs. the pre-lift chain
453    ///
454    /// `.is_some_and(|e| e.exports.<f>(k))` returns `false` on the
455    /// three "no ephemeral" outcomes (Permanent, default, Ambiguous)
456    /// AND on the "ephemeral with no matching export" outcome. The
457    /// lifted projection returns `&[]` on the three "no ephemeral"
458    /// outcomes and the actual slice on the Ephemeral outcome; each
459    /// [`ExportSpecSliceExt`] method returns `false` on `&[]` via
460    /// `.iter().any(...)`. The composition is therefore observationally
461    /// equivalent to the pre-lift chain at every callsite.
462    ///
463    /// A future third [`Lifetime`] variant carrying its own `exports`
464    /// slot (or an `Ambiguous` corner promoted to project the
465    /// ephemeral half instead of collapsing to `None`) lands at ONE
466    /// arm here — every downstream `export-*` require-tag family
467    /// picks it up mechanically, with no per-caller edit.
468    ///
469    /// # Peer to [`Self::resolved_ephemeral`]
470    ///
471    /// Both compose against the SAME [`Self::variant`] resolver; this
472    /// primitive is the specialization for the `exports`-only walk
473    /// (skips the `TTL` / `teardown_policy` / `max_concurrent` slots
474    /// the peer projection exposes), returning the slice directly so
475    /// consumers whose call graph terminates on
476    /// [`ExportSpecSliceExt`] compose without an intermediate
477    /// `.is_some_and(...)` step.
478    ///
479    /// Theory anchor: THEORY.md §II.1 invariant 5 (composition
480    /// preserves proofs — the Option-carrier collapse lives at ONE
481    /// substrate site so every downstream `export-<kind>` require-tag
482    /// family binds through the SAME shape). THEORY.md §VI.1
483    /// (generation over composition — a new slice-level probe on
484    /// [`ExportSpec`] reaches this projection through the SAME
485    /// trait-method shape without a bespoke Option-arm at the caller).
486    ///
487    /// Pinned by
488    /// [`tests::ephemeral_exports_returns_empty_slice_on_permanent_default_and_ambiguous_lifetime`],
489    /// [`tests::ephemeral_exports_returns_declared_exports_slice_on_ephemeral_lifetime`],
490    /// and
491    /// [`tests::ephemeral_exports_slice_matches_resolved_ephemeral_exports_pointer_when_present`].
492    #[must_use]
493    pub fn ephemeral_exports(&self) -> &[ExportSpec] {
494        match self.resolved_ephemeral() {
495            Some(e) => e.exports.as_slice(),
496            None => &[],
497        }
498    }
499}
500
501const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
502
503/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
504/// SIGTERM terminates only on explicit operator action.
505#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
506#[serde(rename_all = "camelCase")]
507pub struct PermanentLifetime {}
508
509/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
510///
511/// Phase semantics:
512/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
513///   reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
514/// - On `Failed`  with `teardown_policy ∈ {OnFailed,   Always}`:
515///   same. Otherwise Process stays at Failed for forensic inspection.
516/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
517///   reconcile loop tick. TTL expiry while in any non-terminal phase
518///   forces SIGTERM regardless of `teardown_policy`.
519#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
520#[serde(rename_all = "camelCase")]
521pub struct EphemeralLifetime {
522    /// `humantime`-parseable duration from `phaseSince(Forking)` after
523    /// which the Process is force-SIGTERM'd.
524    #[serde(default = "default_ephemeral_ttl")]
525    pub ttl: String,
526
527    /// When the Process auto-terminates.
528    #[serde(default)]
529    pub teardown_policy: TeardownPolicy,
530
531    /// Cluster-wide concurrency budget across ephemeral Processes that
532    /// share the same `spec.identity.name_override` / chart_ref.
533    /// `0` = no cap. Enforced by the reconciler before transitioning out
534    /// of `Pending`.
535    #[serde(default = "default_ephemeral_max_concurrent")]
536    pub max_concurrent: u32,
537
538    /// Declared exports — what artifacts survive teardown and where
539    /// they flow. Empty (default) = nothing survives, matching the
540    /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
541    /// independently triggered during the reconciler's `Releasing`
542    /// phase against the terminal `ProcessPhase` reached.
543    ///
544    /// See [`crate::export`] for the full type. All exports flow
545    /// through the pleme-io Vector + NATS layer — there is no
546    /// per-spec ad-hoc sink.
547    #[serde(default, skip_serializing_if = "Vec::is_empty")]
548    pub exports: Vec<ExportSpec>,
549}
550
551impl EphemeralLifetime {
552    /// The [`humantime`]-parsed `self.ttl` duration, or `None` if the
553    /// operator-authored `ttl` string doesn't parse — the one-line
554    /// collapse of the `humantime::parse_duration(&<eph>.ttl).ok()`
555    /// chain lifted to ONE typed owner past the ★★ PRIME-DIRECTIVE
556    /// ≥ 2 duplication threshold.
557    ///
558    /// Pre-lift the SAME chain was hand-authored at TWO workspace-wide
559    /// consumer sites in [`crate::lifetime_clock`], both walking
560    /// `humantime::parse_duration(&<ephemeral>.ttl)` on an
561    /// `&EphemeralLifetime` and discarding the parse-error arm to the
562    /// downstream "skip the timed decision" branch:
563    ///
564    /// * [`crate::lifetime_clock::evaluate`] — the TTL-expiry gate.
565    ///   Reads `if let Ok(ttl) = humantime::parse_duration(&ephemeral
566    ///   .ttl) { … }` inside the non-terminal-phase guard, comparing
567    ///   the parsed `Duration` against the wall-clock elapsed distance
568    ///   from `metadata.creation_timestamp` to fire
569    ///   `AutoTerminate::Now { TtlExpired }`.
570    /// * [`crate::lifetime_clock::requeue_with_ttl`] — the sleep-
571    ///   budget picker for the reconciler's next requeue. Reads
572    ///   `let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
573    ///   return default; };` and short-circuits to the caller's
574    ///   `default` sleep budget on parse failure.
575    ///
576    /// Both sites walked the SAME `humantime::parse_duration(&<eph>
577    /// .ttl)` chain and both wanted the Option-shape (the `Ok` arm as
578    /// the parsed `Duration`, the `Err` arm collapsed to the
579    /// downstream skip-branch). Post-lift each caller reaches for
580    /// `<eph>.ttl_duration()` and applies its own tail at its own
581    /// site (`if let Some(ttl) = …` for the guard, `let Some(ttl) =
582    /// … else { return default; }` for the sleep-budget picker).
583    ///
584    /// Return-form axis: `Option<std::time::Duration>` matches the
585    /// downstream comparator's type. The peer projection
586    /// [`crate::time::elapsed_since`] returns the SAME
587    /// `Option<std::time::Duration>` shape, so the TTL-expiry gate's
588    /// `elapsed >= ttl` comparator and the sleep-budget picker's
589    /// `ttl.checked_sub(elapsed)` subtraction each land with both
590    /// operands on the same axis, no per-consumer conversion.
591    ///
592    /// The `None` arm is the "operator's ttl string doesn't parse"
593    /// corner — a typo (`"1our"`), an unsupported unit, a
594    /// non-humantime literal that reached the field. Every consumer
595    /// interprets the corner as "no ttl data → don't fire the timed
596    /// decision" — [`crate::lifetime_clock::evaluate`] skips the
597    /// `AutoTerminate::Now` branch, [`crate::lifetime_clock::
598    /// requeue_with_ttl`] returns the caller's `default` sleep
599    /// budget. The pins below bind that shape.
600    ///
601    /// A future normalization (a per-fleet minimum TTL floor before
602    /// the humantime cast, a canonical unit-normalization pass, a
603    /// warn-log on unparseable strings) lands at THIS ONE substrate
604    /// primitive and every downstream ephemeral-TTL consumer inherits
605    /// the upgrade mechanically — no per-site edit at either of the
606    /// TWO listed callers or at future consumers (an allocation-TTL
607    /// remaining-budget picker, a pool free-TTL floor gate, a
608    /// stable-name claim-arbiter max-age tie-break).
609    ///
610    /// Sibling substrate primitive on the same
611    /// `(humantime string × Option<Duration>) → Option<Duration>`
612    /// axis: [`crate::time::elapsed_since`] — the `(now, anchor) →
613    /// Option<Duration>` peer that every timed-decision gate
614    /// composes with THIS primitive to produce an `elapsed >= ttl` /
615    /// `ttl.checked_sub(elapsed)` comparison.
616    ///
617    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
618    /// the `humantime::parse_duration(&<eph>.ttl).ok()` chain recurred
619    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
620    /// duplication trigger, and is lifted to ONE owner here).
621    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
622    /// the pins bind the parse-failure corner AND the empty-ttl corner
623    /// AND the humantime edge shapes AND the return-form parity with
624    /// [`crate::time::elapsed_since`], so a regression that drifts any
625    /// surface fails at `tests::ttl_duration_*` here rather than as
626    /// silent operator-facing skew between the TTL-expiry gate and
627    /// the sleep-budget picker on the SAME EphemeralLifetime).
628    #[must_use]
629    pub fn ttl_duration(&self) -> Option<std::time::Duration> {
630        humantime::parse_duration(&self.ttl).ok()
631    }
632
633    /// True iff any declared export's [`crate::export::ExportTrigger`]
634    /// fires for the given terminal-reached phase. The reconciler
635    /// uses this to decide whether to route `Attested`/`Failed`
636    /// through `Releasing` (the export window) or skip straight to
637    /// `Exiting`/`Zombie`.
638    ///
639    /// Returns `false` when the export list is empty or no trigger
640    /// matches — both cases collapse to the existing teardown path.
641    ///
642    /// Thin delegate to [`ExportSpecSliceExt::has_applicable_at`] on
643    /// `self.exports`. Both callers (this method and the peer
644    /// [`crate::ephemeral::EphemeralSpec::has_applicable_exports_at`]
645    /// on the sugar surface) route through the SAME slice-level
646    /// substrate primitive so a future normalization at the compound
647    /// `(when, phase) → fires_on(phase)` walk lands at ONE site.
648    pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
649        self.exports.has_applicable_at(phase)
650    }
651
652    /// Iterate over the exports whose trigger fires on `phase`.
653    /// The reconciler's `handle_releasing` consumes this to emit
654    /// one tatara-export-worker Job per surviving spec.
655    pub fn applicable_exports(
656        &self,
657        phase: ProcessPhase,
658    ) -> impl Iterator<Item = &ExportSpec> + '_ {
659        self.exports.iter().filter(move |e| e.when.fires_on(phase))
660    }
661
662    /// Scalar-carrier presence probe on the defaulted `teardown_policy`
663    /// slot — `true` iff this ephemeral lifetime carries the queried
664    /// [`TeardownPolicy`] variant. The one-line collapse of the
665    /// `<eph>.teardown_policy == kind` closure body lifted to ONE
666    /// substrate owner past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
667    /// threshold — the eighteenth closed-set-driven prefix family in
668    /// [`tatara-check`]'s point-domain require-tag classifier
669    /// (`teardown-policy-<kind>`) is the first workspace-wide consumer,
670    /// but the shape is a peer of [`crate::spec::SignalPolicy::has_sighup_strategy`]
671    /// on the SAME `(defaulted-scalar-field, closed-set-discriminator) → bool`
672    /// axis with the parent reached through the compound
673    /// [`Lifetime::resolved_ephemeral`] Option-hop.
674    ///
675    /// # Sibling scalar-carrier probes
676    ///
677    /// * [`crate::spec::SignalPolicy::has_sighup_strategy`] — required
678    ///   parent × defaulted scalar child (`SignalPolicy` is always
679    ///   present on `ProcessSpec`; `sighup_strategy` defaults to
680    ///   `SighupStrategy::Reconverge`).
681    /// * [`crate::classification::Classification::has_calm`] and
682    ///   [`crate::classification::Classification::has_data_classification`]
683    ///   — required parent × defaulted scalar child on the six-axis
684    ///   classification lattice.
685    /// * THIS — Option parent (`resolved_ephemeral()` may return `None`
686    ///   on a `Permanent` lifetime or an ambiguous `Lifetime`) ×
687    ///   defaulted scalar child ([`TeardownPolicy`] defaults to
688    ///   `Always`). Opens the (Option-parent × defaulted-scalar-child)
689    ///   corner of the presence-probe algebra, distinct from the
690    ///   (Option-parent × slice-child) corner already populated by
691    ///   [`crate::export::ExportSpecSliceExt::has_when`] and its three
692    ///   slice-level siblings on the SAME `Vec<ExportSpec>` slot.
693    ///
694    /// # Semantics — VARIANT match, not POPULATED slot
695    ///
696    /// `has_teardown_policy(kind)` returns `true` iff
697    /// `self.teardown_policy == kind`. On an
698    /// [`EphemeralLifetime::default`]
699    /// (`teardown_policy: TeardownPolicy::default() = Always`) the
700    /// probe returns `true` for [`TeardownPolicy::Always`] and `false`
701    /// for every other variant — distinct from the Option-slot axis
702    /// where a default carrier returns `false` for EVERY kind. An
703    /// operator who left `:lifetime :ephemeral :teardown-policy` at
704    /// the substrate default IS configured for `Always`, and a
705    /// `:requires (teardown-policy-Always)` check should pass; only an
706    /// operator who deliberately overrode the policy to `OnAttested` /
707    /// `OnFailed` / `Never` fails the tag on this axis.
708    ///
709    /// # Compounding
710    ///
711    /// A future fifth [`TeardownPolicy`] variant added to `ALL` (a
712    /// hypothetical `OnTimeout` for "tear down only on TTL expiry",
713    /// pre-flagged on the closed set's `ALL` docstring) reaches this
714    /// probe through ONE `ALL` entry + one `as_str` arm + one
715    /// `should_teardown_on` arm alone, no per-caller edit at the
716    /// `teardown-policy-<kind>` require-tag classifier and no per-
717    /// consumer restatement of the `self.teardown_policy == kind`
718    /// closure body.
719    ///
720    /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
721    /// preserves proofs; the scalar-carrier presence-probe body lives
722    /// at ONE substrate site so every downstream (`teardown-policy-<kind>`
723    /// require-tag family in tatara-check, closed-set audit
724    /// dispatchers, future variant additions on [`TeardownPolicy`])
725    /// binds through the SAME `has(kind)` shape rather than restating
726    /// the `<eph>.teardown_policy == kind` closure body at each call
727    /// site. THEORY.md §VI.1 — generation over composition; a future
728    /// variant lands at ONE `ALL` entry + one `as_str` arm on the
729    /// closed set and the probe picks it up mechanically without
730    /// further per-consumer edits.
731    #[must_use]
732    pub fn has_teardown_policy(&self, kind: TeardownPolicy) -> bool {
733        self.teardown_policy == kind
734    }
735
736    /// Derived-bool-predicate presence probe on the stored
737    /// [`Self::teardown_policy`] slot — `true` iff this ephemeral
738    /// lifetime's [`TeardownPolicy`] would auto-SIGTERM the Process on
739    /// the queried [`ProcessPhase`] transition (as read through
740    /// [`TeardownPolicy::should_teardown_on`]).
741    ///
742    /// The one-line collapse of the
743    /// `<eph>.teardown_policy.should_teardown_on(phase)` closure body
744    /// lifted to ONE substrate owner past the ★★ PRIME-DIRECTIVE ≥ 2
745    /// duplication threshold — the `teardown-fires-on-<phase>` require-
746    /// tag prefix family in [`tatara-check`]'s point-domain require-tag
747    /// classifier is the first workspace-wide consumer (composed
748    /// through the [`crate::lifetime::Lifetime::resolved_ephemeral`]
749    /// Option-hop), the live reconciler decision at
750    /// [`crate::lifetime_clock::evaluate`] reaches the SAME
751    /// [`TeardownPolicy::should_teardown_on`] projection independently
752    /// today — so a future normalization at the predicate body (widening
753    /// the return to `Option<TeardownReason>` for deeper diagnostics,
754    /// adding a debug-build assertion on redundant duplicate wiring, an
755    /// eventual audit hook naming the (policy, phase, decision) triad)
756    /// lands at ONE site here and every downstream picks it up
757    /// mechanically.
758    ///
759    /// # NEW representation-kind corner — Option-parent × derived-bool-predicate-over-<phase>-child
760    ///
761    /// The workspace-wide closed-set-driven presence-probe algebra
762    /// grows a new corner here: the probe body composes a
763    /// STORED-scalar's typed PREDICATE against the operator-supplied
764    /// closed-set argument, so the answer reads
765    /// `self.teardown_policy.should_teardown_on(phase)` rather than the
766    /// prior derived-Option-child shape
767    /// (`self.<field>.<projection>() == Some(phase)` on
768    /// [`crate::spec::SignalPolicy::has_sighup_target`] +
769    /// [`crate::compliance::ComplianceBindingSliceExt::has_verification_gates`]).
770    /// The projection is many-to-one via the predicate closure rather
771    /// than the closed-set match: [`TeardownPolicy::Always`] fires on
772    /// BOTH [`ProcessPhase::Attested`] AND [`ProcessPhase::Failed`], so
773    /// `has_teardown_firing_on(Attested)` returns `true` for both the
774    /// `Always` and `OnAttested` policies while
775    /// `has_teardown_policy(Always)` and `has_teardown_policy(OnAttested)`
776    /// each return `true` for exactly one policy. Both probes coexist
777    /// because they answer distinct operator questions: the raw variant
778    /// probe pins the AUTHORED policy variant; the predicate probe pins
779    /// the ACTIONABLE phase transitions that variant fires on.
780    ///
781    /// # Semantics — PREDICATE match, not raw-variant equality nor Option-projection
782    ///
783    /// * `has_teardown_firing_on(ProcessPhase::Attested)` returns
784    ///   `true` iff the stored policy is
785    ///   [`TeardownPolicy::Always`] OR [`TeardownPolicy::OnAttested`].
786    /// * `has_teardown_firing_on(ProcessPhase::Failed)` returns
787    ///   `true` iff the stored policy is
788    ///   [`TeardownPolicy::Always`] OR [`TeardownPolicy::OnFailed`].
789    /// * Every non-terminal [`ProcessPhase`] returns `false` for
790    ///   every stored policy — teardown is a terminal-phase decision
791    ///   ([`TeardownPolicy::should_teardown_on`] short-circuits every
792    ///   non-terminal arm to `false`).
793    /// * [`TeardownPolicy::Never`] returns `false` for every
794    ///   [`ProcessPhase`] — the substrate default opt-out.
795    ///
796    /// # Sibling derived-child probes
797    ///
798    /// * [`crate::spec::SignalPolicy::has_sighup_target`] — required-
799    ///   parent × derived-Option-child. Peer on the "derived child"
800    ///   axis, but the derivation is an `Option<K>`-typed projection
801    ///   read for equality (`self.<field>.<projection>() ==
802    ///   Some(phase)`), not a boolean predicate over the closed-set
803    ///   argument.
804    /// * [`crate::compliance::ComplianceBindingSliceExt::has_verification_gates`]
805    ///   — slice-parent × derived-Option-typed-projection-child. Peer
806    ///   on the "derived child" axis, walked over a slice.
807    /// * THIS — Option parent
808    ///   ([`crate::lifetime::Lifetime::resolved_ephemeral`] may return
809    ///   `None` on a `Permanent` lifetime or an ambiguous `Lifetime`,
810    ///   so the require-tag classifier reads THIS probe through the
811    ///   `is_some_and` hop) × derived-bool-predicate child. First
812    ///   occupant on the (Option-parent × derived-bool-predicate-over-
813    ///   <closed-set>-child) corner of the workspace-wide presence-
814    ///   probe algebra: the child probe combines the stored scalar
815    ///   with the closed-set argument through a typed BOOLEAN
816    ///   predicate ([`TeardownPolicy::should_teardown_on`]), not
817    ///   through equality on an [`Option<K>`] projection.
818    ///
819    /// # Compounding
820    ///
821    /// The point-domain require-tag surface in
822    /// `tatara-reconciler::bin::tatara-check` composes this primitive
823    /// with the closed-set [`crate::phase::ProcessPhase`]'s
824    /// autoderived `FromStr` through the
825    /// `strip_and_classify_prefixed_kind` substrate to publish the
826    /// TWENTY-EIGHTH closed-set-driven prefix family in the point-
827    /// domain classifier's dispatch table
828    /// (`teardown-fires-on-<phase>`), one axis over from the sibling
829    /// `teardown-policy-<kind>` family that composes through
830    /// [`Self::has_teardown_policy`] against the same stored slot.
831    ///
832    /// A future [`TeardownPolicy`] variant (a hypothetical `OnTimeout`
833    /// for "tear down only on TTL expiry") reaches this probe through
834    /// ONE `ALL` entry + one `as_str` arm + one `should_teardown_on`
835    /// arm alone — no per-caller edit at this composition, no per-
836    /// consumer restatement of the
837    /// `<eph>.teardown_policy.should_teardown_on(phase)` closure body.
838    /// A future [`crate::phase::ProcessPhase`] variant paired with a
839    /// [`TeardownPolicy`] variant that fires on it is reached by
840    /// extending the `should_teardown_on` match — again ONE substrate
841    /// edit and every downstream inherits the shift.
842    ///
843    /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
844    /// preserves proofs; the derived-bool-predicate presence-probe
845    /// composition (`self.teardown_policy.should_teardown_on(phase)`)
846    /// lives at ONE substrate site so every downstream
847    /// (`teardown-fires-on-<phase>` require-tag family in tatara-check,
848    /// closed-set audit dispatchers, future variant additions on
849    /// either [`TeardownPolicy`] or
850    /// [`crate::phase::ProcessPhase`]) binds through the SAME
851    /// `has_teardown_firing_on(phase)` shape rather than restating the
852    /// `<eph>.teardown_policy.should_teardown_on(phase)` chain at each
853    /// callsite. THEORY.md §VI.1 — generation over composition; the
854    /// [`TeardownPolicy::should_teardown_on`] match is the ONE per-
855    /// (variant, phase) edit site every future policy or phase variant
856    /// reaches through, and this method inherits the shift
857    /// automatically.
858    #[must_use]
859    pub const fn has_teardown_firing_on(&self, phase: ProcessPhase) -> bool {
860        self.teardown_policy.should_teardown_on(phase)
861    }
862}
863
864impl Default for EphemeralLifetime {
865    fn default() -> Self {
866        Self {
867            ttl: default_ephemeral_ttl(),
868            teardown_policy: TeardownPolicy::default(),
869            max_concurrent: default_ephemeral_max_concurrent(),
870            exports: Vec::new(),
871        }
872    }
873}
874
875/// Workspace-canonical humantime default TTL for every ephemeral
876/// authoring surface — the ONE substrate owner of the `"1h"` wire-form
877/// default that pre-lift lived as THREE identical private
878/// `fn default_ttl() -> String { "1h".to_string() }` shims across
879/// [`tatara-process`]'s own [`EphemeralLifetime`] + [`crate::ephemeral::
880/// EphemeralSpec`] + [`tatara-reconciler`]'s `EphemeralDefaults`.
881///
882/// Pre-lift the SAME string wire-form `"1h"` was serde-defaulted at
883/// THREE workspace-wide `#[serde(default = "default_ttl")]` slots past
884/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each carrying its
885/// own private `fn default_ttl() -> String { "1h".to_string() }` shim:
886///
887/// * [`EphemeralLifetime::ttl`] (this module) — the canonical
888///   lifetime-slot default. Round-tripped through
889///   [`EphemeralLifetime::default`] and every serde-deserialize of a
890///   `spec.lifetime.ephemeral` block whose `ttl:` field is omitted.
891/// * [`crate::ephemeral::EphemeralSpec::ttl`] — the `(defephemeral …)`
892///   Lisp authoring surface's serde default, the wire-form default a
893///   `defephemeral` form binds when the operator omits `:ttl`.
894/// * `tatara-reconciler::ephemeral_defaults::EphemeralDefaults::default_ttl`
895///   — the reconciler's operator-configured cluster-wide default TTL,
896///   which itself defaults to `"1h"` when the operator omits it from
897///   the shikumi config file.
898///
899/// All three shims returned bytewise-identical `"1h"` and served the
900/// SAME wire-form default. Any operator-facing re-tuning of the
901/// workspace-canonical default (a shift to `"30m"` for tighter env
902/// recycling, a shift to `"6h"` for long-running attestation suites,
903/// a per-fleet override sourced from a substrate config) pre-lift
904/// required a THREE-site coordinated edit; any drift silently produced
905/// operator-visible skew where `defephemeral :ttl <omitted>` defaulted
906/// to X but the reconciler's own default landed at Y for the SAME
907/// authoring surface. Post-lift every serde slot reads through this
908/// ONE substrate owner and the invariant "every ephemeral-default
909/// surface names the SAME humantime string" holds by construction.
910///
911/// Return-form axis: `String` — matches the serde `default = "…"` slot
912/// contract exactly (serde invokes the named function and stamps its
913/// returned owned value into the field). The paired [`DEFAULT_EPHEMERAL_TTL`]
914/// const exposes the underlying `&'static str` for callers that want
915/// the zero-allocation handle (a compile-time `assert_eq!` pin, a
916/// format-string argument, an ephemeral-context error message).
917///
918/// A future normalization on the workspace-canonical ephemeral TTL
919/// default (a fleet-wide re-tuning, a per-cluster override injected
920/// via a `TATARA_DEFAULT_EPHEMERAL_TTL` env var, a bounded-precision
921/// canonicalization to a specific humantime spelling like `"3600s"`)
922/// lands at THIS ONE substrate primitive and every downstream serde-
923/// default consumer inherits the upgrade mechanically — no per-site
924/// edit at any of the THREE listed callers or at future consumers (a
925/// new ephemeral-adjacent authoring surface, a fleet-wide dashboard
926/// that reads the canonical default, a new tatara-eval fixture).
927///
928/// Peer to [`default_ephemeral_max_concurrent`] on the "workspace-
929/// canonical ephemeral defaults" axis — both lift a THREE-way-
930/// duplicated (TTL) or TWO-way-duplicated (max-concurrent) private
931/// `fn default_*` shim onto ONE substrate owner. The paired
932/// [`DEFAULT_EPHEMERAL_TTL`] const and [`DEFAULT_EPHEMERAL_MAX_CONCURRENT`]
933/// const partition the same axis on the "typed handle over the
934/// wire-form default" side.
935///
936/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
937/// `"1h".to_string()` wire-form default recurred at THREE hand-authored
938/// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning
939/// two workspace crates, and is lifted onto ONE workspace-wide
940/// substrate owner here). THEORY.md §II.1 invariant 5 (composition
941/// preserves proofs — the pins bind the default at fail-before-pass-
942/// after granularity so a regression that drifted the wire-form
943/// surfaces at [`tests::default_ephemeral_ttl_matches_pre_lift_1h_string`]
944/// rather than as silent operator-facing skew across the three
945/// downstream consumers).
946#[must_use]
947pub fn default_ephemeral_ttl() -> String {
948    DEFAULT_EPHEMERAL_TTL.to_string()
949}
950
951/// Workspace-canonical humantime default TTL wire-form — the `&'static
952/// str` handle over the same `"1h"` value [`default_ephemeral_ttl`]
953/// returns. Use this const for compile-time comparisons and format
954/// arguments; use [`default_ephemeral_ttl`] for the owned `String` the
955/// serde `default = "…"` slot contract expects.
956pub const DEFAULT_EPHEMERAL_TTL: &str = "1h";
957
958/// Workspace-canonical default cluster-wide concurrency budget for
959/// every ephemeral authoring surface — the ONE substrate owner of the
960/// `1` wire-form default that pre-lift lived as TWO identical private
961/// `fn default_max_concurrent() -> u32 { 1 }` shims across
962/// [`EphemeralLifetime`] + [`crate::ephemeral::EphemeralSpec`].
963///
964/// Pre-lift the SAME `1u32` wire-form was serde-defaulted at TWO
965/// `tatara-process` `#[serde(default = "default_max_concurrent")]`
966/// slots past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each
967/// carrying its own private shim:
968///
969/// * [`EphemeralLifetime::max_concurrent`] (this module) — the
970///   lifetime-slot default: "at most one ephemeral Process per
971///   `spec.identity.name_override` / chart_ref concurrently until the
972///   operator explicitly widens the budget".
973/// * [`crate::ephemeral::EphemeralSpec::max_concurrent`] — the
974///   `(defephemeral …)` Lisp authoring surface's serde default, the
975///   same conservative "one at a time" invariant a fresh `defephemeral`
976///   binds when the operator omits `:max-concurrent`.
977///
978/// Both shims returned `1` and served the SAME "one-at-a-time"
979/// concurrency invariant. Post-lift both serde slots read through
980/// this ONE substrate owner; a future re-tuning of the workspace-
981/// canonical concurrency invariant (a shift to `2` for parallel-safe
982/// probes, a shift to `0` for uncapped ephemeral fleets, a per-fleet
983/// override) lands at ONE site and both downstream consumers inherit
984/// the upgrade mechanically.
985///
986/// NOT the same axis as `tatara-reconciler::ephemeral_defaults::
987/// EphemeralDefaults::max_concurrent_per_cluster`, which defaults to
988/// `0` (no cap) on purpose — that field is the operator's cluster-
989/// wide ceiling, whereas THIS default is the per-authoring-surface
990/// conservative "one-at-a-time" invariant. The two defaults are
991/// deliberately different values on deliberately different axes and
992/// stay separate.
993///
994/// Peer to [`default_ephemeral_ttl`] on the "workspace-canonical
995/// ephemeral defaults" axis — see that primitive's doc for the shared
996/// motivation.
997#[must_use]
998pub fn default_ephemeral_max_concurrent() -> u32 {
999    DEFAULT_EPHEMERAL_MAX_CONCURRENT
1000}
1001
1002/// Workspace-canonical default cluster-wide concurrency budget wire-
1003/// form — the `u32` handle over the same `1` value
1004/// [`default_ephemeral_max_concurrent`] returns. Use this const for
1005/// compile-time comparisons; use [`default_ephemeral_max_concurrent`]
1006/// for the serde `default = "…"` slot contract.
1007pub const DEFAULT_EPHEMERAL_MAX_CONCURRENT: u32 = 1;
1008
1009/// When an ephemeral Process self-terminates.
1010///
1011/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
1012/// foreign success/failure language — typed phases are the source of truth.
1013#[derive(
1014    Clone,
1015    Copy,
1016    Debug,
1017    PartialEq,
1018    Eq,
1019    Hash,
1020    Serialize,
1021    Deserialize,
1022    JsonSchema,
1023    Default,
1024    tatara_closed_set::DeriveClosedSet,
1025)]
1026#[serde(rename_all = "PascalCase")]
1027#[closed_set(via = "as_str", display, generate_unknown)]
1028pub enum TeardownPolicy {
1029    /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
1030    #[default]
1031    Always,
1032    /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
1033    OnAttested,
1034    /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
1035    /// TTL or explicit operator SIGTERM.
1036    OnFailed,
1037    /// Never auto-terminate (TTL still applies).
1038    Never,
1039}
1040
1041impl TeardownPolicy {
1042    /// The closed set of teardown policies — single source of truth that
1043    /// drives the `as_str` / Display / `FromStr` triad and the typed
1044    /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
1045    /// variant lands at one `ALL` entry + one `as_str` arm + one
1046    /// `should_teardown_on` arm — exhaustively checked by the compiler
1047    /// (the `[Self; 4]` array literal forces the arity).
1048    ///
1049    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
1050    /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
1051    /// [`crate::boundary::ConditionKind::ALL`],
1052    /// [`crate::phase::ProcessPhase::ALL`],
1053    /// [`crate::signal::ProcessSignal::ALL`].
1054    pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
1055
1056    /// Canonical PascalCase wire-format projection — matches the serde
1057    /// `rename_all = "PascalCase"` output verbatim. Used by Display
1058    /// (single source of truth), by `FromStr` to identify the variant
1059    /// from its annotation / status-field representation, and by
1060    /// operator-facing reason strings the reconciler stamps without
1061    /// reaching for `{:?}` Debug formatting. Pinned by
1062    /// `teardown_policy_as_str_matches_serde`.
1063    pub const fn as_str(self) -> &'static str {
1064        match self {
1065            Self::Always => "Always",
1066            Self::OnAttested => "OnAttested",
1067            Self::OnFailed => "OnFailed",
1068            Self::Never => "Never",
1069        }
1070    }
1071
1072    /// True iff, given a `ProcessPhase`, this policy says "tear down."
1073    /// ONE typed dispatch over the typed phase enum that replaces the
1074    /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
1075    /// predicates `lifetime_clock::evaluate` previously branched on.
1076    /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
1077    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
1078    /// always return `false` — teardown is a terminal-phase decision.
1079    ///
1080    /// The legacy [`Self::should_teardown_on_attested`] /
1081    /// [`Self::should_teardown_on_failed`] predicates remain as thin
1082    /// delegates so existing call sites keep their narrow signatures;
1083    /// the truth table is pinned by
1084    /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
1085    pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
1086        match phase {
1087            ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
1088            ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
1089            ProcessPhase::Pending
1090            | ProcessPhase::Forking
1091            | ProcessPhase::Execing
1092            | ProcessPhase::Running
1093            | ProcessPhase::Reconverging
1094            | ProcessPhase::Releasing
1095            | ProcessPhase::Exiting
1096            | ProcessPhase::Zombie
1097            | ProcessPhase::Reaped => false,
1098        }
1099    }
1100
1101    /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
1102    /// case — kept so existing call sites (notably the truth-table
1103    /// test in this module) keep their narrow signature without
1104    /// reaching for the typed-phase variant.
1105    pub const fn should_teardown_on_attested(self) -> bool {
1106        self.should_teardown_on(ProcessPhase::Attested)
1107    }
1108
1109    /// Symmetric delegate to [`Self::should_teardown_on`] for the
1110    /// `Failed` case.
1111    pub const fn should_teardown_on_failed(self) -> bool {
1112        self.should_teardown_on(ProcessPhase::Failed)
1113    }
1114}
1115
1116// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
1117// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
1118// `pub struct UnknownTeardownPolicy(pub String)` are generated by
1119// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
1120// "as_str", display, generate_unknown)]` on the enum declaration above.
1121// The auto-derived label `"teardown policy"` matches the prior hand-
1122// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
1123// inherent `as_str` projection stays load-bearing — the PascalCase
1124// wire-format that matches the serde rename + the reconciler's reason-
1125// string emission verbatim — while the trait method `label` gives
1126// generic consumers a STABLE name across the 36+ workspace-wide
1127// closed-set implementors.
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132
1133    #[test]
1134    fn default_lifetime_resolves_to_permanent() {
1135        let l = Lifetime::default();
1136        assert!(l.is_default());
1137        assert!(!l.is_ephemeral());
1138        assert!(matches!(
1139            l.variant().unwrap(),
1140            LifetimeVariant::Permanent(_)
1141        ));
1142    }
1143
1144    #[test]
1145    fn ephemeral_set_resolves() {
1146        // Routes through the ONE substrate composer
1147        // [`Lifetime::ephemeral`] — see the composer's doc-comment for
1148        // the full migration rationale.
1149        let l = Lifetime::ephemeral(EphemeralLifetime::default());
1150        assert!(l.is_ephemeral());
1151        match l.variant().unwrap() {
1152            LifetimeVariant::Ephemeral(e) => {
1153                assert_eq!(e.ttl, "1h");
1154                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
1155                assert_eq!(e.max_concurrent, 1);
1156            }
1157            other => panic!("expected ephemeral, got {other:?}"),
1158        }
1159    }
1160
1161    #[test]
1162    fn ambiguous_lifetime_errors() {
1163        let l = Lifetime {
1164            permanent: Some(PermanentLifetime {}),
1165            ephemeral: Some(EphemeralLifetime::default()),
1166        };
1167        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1168    }
1169
1170    #[test]
1171    fn teardown_policy_dispatch() {
1172        assert!(TeardownPolicy::Always.should_teardown_on_attested());
1173        assert!(TeardownPolicy::Always.should_teardown_on_failed());
1174        assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
1175        assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
1176        assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
1177        assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
1178        assert!(!TeardownPolicy::Never.should_teardown_on_attested());
1179        assert!(!TeardownPolicy::Never.should_teardown_on_failed());
1180    }
1181
1182    // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
1183    //    should_teardown_on(phase)) ─
1184
1185    /// Structural well-formedness of [`TeardownPolicy`] as a
1186    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1187    /// testkit lift that pins all three structural invariants (`ALL`
1188    /// is non-empty, every variant round-trips through `label ↔
1189    /// parse_label`, labels are pairwise distinct, `""` is outside the
1190    /// closed set) at ONE call site. Replaces the hand-derived
1191    /// `teardown_policy_all_is_unique_and_complete` +
1192    /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
1193    /// `unknown_teardown_policy_errors`. `FromStr` delegates to
1194    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1195    /// exercises the same code path the reconciler hits when parsing a
1196    /// CRD `enum:`-validated value back to the typed policy.
1197    #[test]
1198    fn teardown_policy_is_well_formed_closed_set() {
1199        tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
1200    }
1201
1202    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1203    /// output verbatim for every variant. A future variant rename
1204    /// (or an `as_str` arm typo) lands here at one site. The reason
1205    /// string `lifetime_clock::evaluate` stamps reaches for the same
1206    /// projection via `Display`, so a Debug-vs-canonical drift would
1207    /// surface here, not in operator-facing reason strings.
1208    #[test]
1209    fn teardown_policy_as_str_matches_serde() {
1210        crate::tagged_union::assert_label_matches_serde_serialization::<TeardownPolicy>();
1211    }
1212
1213    /// The Display impl IS `as_str` — pinning this lets future
1214    /// callers (notably `lifetime_clock::evaluate`'s reason string)
1215    /// reach for either projection without drift.
1216    #[test]
1217    fn teardown_policy_display_matches_as_str() {
1218        crate::tagged_union::assert_display_matches_label::<TeardownPolicy>();
1219    }
1220
1221    /// `FromStr` rejects strings that aren't in the canonical
1222    /// projection — lowercased / typo / unrelated — and the error
1223    /// echoes the input verbatim so the operator-facing diagnostic
1224    /// carries the offending value, not a normalized form. The
1225    /// empty-input arm is pinned by
1226    /// [`teardown_policy_is_well_formed_closed_set`] via the
1227    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1228    /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
1229    /// newtype, which the trait's `make_unknown` can't see.
1230    #[test]
1231    fn unknown_teardown_policy_errors() {
1232        use std::str::FromStr;
1233        for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
1234            let err = TeardownPolicy::from_str(bad).unwrap_err();
1235            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1236        }
1237    }
1238
1239    /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
1240    /// the documented (policy, phase) → bool table for every variant
1241    /// at every typed phase. The two terminal phases (Attested,
1242    /// Failed) carry the policy-specific result; every non-terminal
1243    /// phase returns `false`. The closed-set sweep over both
1244    /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
1245    /// variant in either enum reaches this test by iteration — no
1246    /// per-test array maintenance.
1247    #[test]
1248    fn teardown_policy_should_teardown_on_truth_table() {
1249        for policy in TeardownPolicy::ALL {
1250            for phase in ProcessPhase::ALL {
1251                let expected = match phase {
1252                    ProcessPhase::Attested => {
1253                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
1254                    }
1255                    ProcessPhase::Failed => {
1256                        matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
1257                    }
1258                    _ => false,
1259                };
1260                assert_eq!(
1261                    policy.should_teardown_on(phase),
1262                    expected,
1263                    "should_teardown_on({policy:?}, {phase:?}) drift",
1264                );
1265            }
1266        }
1267    }
1268
1269    /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
1270    /// `should_teardown_on_failed` predicates agree with the typed
1271    /// `should_teardown_on(phase)` dispatch they delegate to, for
1272    /// every variant. A regression that re-introduces an inline
1273    /// `matches!` in either legacy predicate fails here the moment
1274    /// `should_teardown_on` is the source of truth.
1275    #[test]
1276    fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
1277        for policy in TeardownPolicy::ALL {
1278            assert_eq!(
1279                policy.should_teardown_on_attested(),
1280                policy.should_teardown_on(ProcessPhase::Attested),
1281                "Attested delegate drift for {policy:?}",
1282            );
1283            assert_eq!(
1284                policy.should_teardown_on_failed(),
1285                policy.should_teardown_on(ProcessPhase::Failed),
1286                "Failed delegate drift for {policy:?}",
1287            );
1288        }
1289    }
1290
1291    // ── scalar-carrier presence probe on EphemeralLifetime ×
1292    //    TeardownPolicy ──
1293    //
1294    // Fail-before-pass-after granularity:
1295    // [`EphemeralLifetime::has_teardown_policy`] did not exist before
1296    // this commit — every consumer of the
1297    // `(EphemeralLifetime, TeardownPolicy) -> bool` scalar-carrier
1298    // probe shape restated the `<eph>.teardown_policy == kind` closure
1299    // body at its own call site. Post-lift the shape lives at ONE
1300    // substrate owner and every downstream (the `teardown-policy-<kind>`
1301    // require-tag family in `tatara-check`, future audit dispatchers
1302    // walking [`TeardownPolicy::ALL`], any future CRD-facing closed-set
1303    // discriminator on a defaulted-scalar `EphemeralLifetime` field)
1304    // binds through the SAME `has(kind)` shape the Option-slot
1305    // (`Intent::has`, `Lifetime::has`), slice-level
1306    // (`ExportSpecSliceExt::has_when` + peers), and required-parent
1307    // scalar-carrier (`SignalPolicy::has_sighup_strategy`) primitives
1308    // publish.
1309
1310    /// DIAGONAL — for every [`TeardownPolicy`] variant, an
1311    /// [`EphemeralLifetime`] whose `teardown_policy` field is set to
1312    /// that variant returns `true` from `has_teardown_policy` on that
1313    /// same variant AND `false` on every other variant. Sweep the
1314    /// [`TeardownPolicy::ALL`] × ALL cross so a regression that
1315    /// hard-coded the arm to a single variant (silently returning
1316    /// `true` on every populated ephemeral regardless of query kind)
1317    /// or wired the equality to a fixed unrelated field fails HERE at
1318    /// the substrate primitive before landing at the operator-facing
1319    /// checks.lisp surface.
1320    #[test]
1321    fn ephemeral_lifetime_has_teardown_policy_returns_true_iff_variant_matches() {
1322        for populated in TeardownPolicy::ALL {
1323            let eph = EphemeralLifetime {
1324                teardown_policy: populated,
1325                ..EphemeralLifetime::default()
1326            };
1327            for query in TeardownPolicy::ALL {
1328                assert_eq!(
1329                    eph.has_teardown_policy(query),
1330                    query == populated,
1331                    "teardown_policy={populated:?}: query {query:?} classification drifted",
1332                );
1333            }
1334        }
1335    }
1336
1337    /// DEFAULT — an [`EphemeralLifetime::default`] carries
1338    /// `teardown_policy: TeardownPolicy::default() = Always`, so the
1339    /// scalar-carrier probe returns `true` on [`TeardownPolicy::Always`]
1340    /// and `false` on every other variant. Distinct from the
1341    /// Option-slot axis where a default carrier returns `false` for
1342    /// EVERY kind — pins the scalar-vs-option semantic split at ONE
1343    /// narrow substrate site so a regression that rewired the probe to
1344    /// Option-slot semantics (returning `false` on the default) fails
1345    /// here.
1346    #[test]
1347    fn ephemeral_lifetime_has_teardown_policy_default_probes_always_only() {
1348        let eph = EphemeralLifetime::default();
1349        for kind in TeardownPolicy::ALL {
1350            let expected = kind == TeardownPolicy::Always;
1351            assert_eq!(
1352                eph.has_teardown_policy(kind),
1353                expected,
1354                "default ephemeral (teardown_policy=Always) must return {expected} for {kind:?}",
1355            );
1356        }
1357    }
1358
1359    // ── derived-bool-predicate presence probe on EphemeralLifetime ×
1360    //    TeardownPolicy × ProcessPhase ──
1361    //
1362    // Fail-before-pass-after granularity:
1363    // [`EphemeralLifetime::has_teardown_firing_on`] did not exist before
1364    // this commit — every consumer of the
1365    // `(EphemeralLifetime, ProcessPhase) -> bool` derived-bool-predicate
1366    // probe shape restated the
1367    // `<eph>.teardown_policy.should_teardown_on(phase)` closure body at
1368    // its own call site (the point-domain require-tag classifier's
1369    // future `teardown-fires-on-<phase>` prefix family; the live
1370    // reconciler decision at [`crate::lifetime_clock::evaluate`], which
1371    // continues to reach [`TeardownPolicy::should_teardown_on`]
1372    // directly today). Post-lift the shape lives at ONE substrate
1373    // owner and every downstream binds through the SAME
1374    // `has_teardown_firing_on(phase)` shape. FIRST occupant on the
1375    // (Option-parent × derived-bool-predicate-over-<closed-set>-child)
1376    // corner of the workspace-wide presence-probe algebra — the child
1377    // probe combines the stored scalar with the closed-set argument
1378    // through a typed BOOLEAN predicate, not through equality on an
1379    // `Option<K>` projection (the prior derived-Option-child corner).
1380
1381    /// TRUTH-TABLE DIAGONAL — for every [`TeardownPolicy`] variant, an
1382    /// [`EphemeralLifetime`] whose `teardown_policy` field is set to
1383    /// that variant returns `has_teardown_firing_on(phase)` in
1384    /// agreement with [`TeardownPolicy::should_teardown_on`] for every
1385    /// [`ProcessPhase`] variant. Sweep the [`TeardownPolicy::ALL`] ×
1386    /// [`ProcessPhase::ALL`] full cross so a regression that (a)
1387    /// hard-coded the arm to a single policy (silently returning
1388    /// `true` on every populated ephemeral regardless of query phase),
1389    /// (b) probed the raw stored field directly (`self.teardown_policy
1390    /// == phase`, which would not even typecheck but would surface at
1391    /// this test if a future variant added a `ProcessPhase`-shaped
1392    /// `teardown_policy` alias), or (c) inverted the predicate
1393    /// direction fails HERE at the substrate primitive before landing
1394    /// at the operator-facing checks.lisp surface.
1395    #[test]
1396    fn ephemeral_lifetime_has_teardown_firing_on_matches_should_teardown_on_per_policy_per_phase() {
1397        for populated in TeardownPolicy::ALL {
1398            let eph = EphemeralLifetime {
1399                teardown_policy: populated,
1400                ..EphemeralLifetime::default()
1401            };
1402            for phase in ProcessPhase::ALL {
1403                assert_eq!(
1404                    eph.has_teardown_firing_on(phase),
1405                    populated.should_teardown_on(phase),
1406                    "teardown_policy={populated:?}, phase={phase:?}: predicate drift from \
1407                     should_teardown_on projection",
1408                );
1409            }
1410        }
1411    }
1412
1413    /// NEVER-ARM PIN — a [`TeardownPolicy::Never`]-carrying
1414    /// [`EphemeralLifetime`] returns `false` for every
1415    /// [`ProcessPhase`] — the substrate default opt-out. Pins the
1416    /// non-firing arm of the predicate against a regression that (a)
1417    /// promoted `Never` to a firing policy at any phase, or (b)
1418    /// inverted the `Never` arm to make it fire on the non-terminal
1419    /// phases (a common wrongfooted rewrite when the reader assumes
1420    /// `Never` means "never SIGTERM, ever" rather than "never
1421    /// auto-terminate on a terminal-gate phase, TTL still applies").
1422    #[test]
1423    fn ephemeral_lifetime_has_teardown_firing_on_returns_false_on_never_for_every_phase() {
1424        let eph = EphemeralLifetime {
1425            teardown_policy: TeardownPolicy::Never,
1426            ..EphemeralLifetime::default()
1427        };
1428        for phase in ProcessPhase::ALL {
1429            assert!(
1430                !eph.has_teardown_firing_on(phase),
1431                "Never-carrying ephemeral must return false for phase={phase:?}",
1432            );
1433        }
1434    }
1435
1436    /// PREDICATE-MANY-TO-ONE PIN — the derived predicate is many-to-
1437    /// one on the (policy, phase) axis: both [`TeardownPolicy::Always`]
1438    /// AND [`TeardownPolicy::OnAttested`] return `true` for
1439    /// [`ProcessPhase::Attested`]; both [`TeardownPolicy::Always`] AND
1440    /// [`TeardownPolicy::OnFailed`] return `true` for
1441    /// [`ProcessPhase::Failed`]. Locks the raw-vs-predicate semantic
1442    /// split at ONE narrow substrate site so a regression that
1443    /// collapsed the predicate to raw variant equality
1444    /// (`self.teardown_policy.as_str() == phase.as_str()` or similar)
1445    /// would fail HERE — a raw match would answer `true` for exactly
1446    /// ONE policy per phase, not for the two policies that fire on
1447    /// each terminal-gate phase.
1448    #[test]
1449    fn ephemeral_lifetime_has_teardown_firing_on_is_many_to_one_on_terminal_gates() {
1450        let always = EphemeralLifetime {
1451            teardown_policy: TeardownPolicy::Always,
1452            ..EphemeralLifetime::default()
1453        };
1454        let on_attested = EphemeralLifetime {
1455            teardown_policy: TeardownPolicy::OnAttested,
1456            ..EphemeralLifetime::default()
1457        };
1458        let on_failed = EphemeralLifetime {
1459            teardown_policy: TeardownPolicy::OnFailed,
1460            ..EphemeralLifetime::default()
1461        };
1462        // Attested is fired on by BOTH Always AND OnAttested.
1463        assert!(always.has_teardown_firing_on(ProcessPhase::Attested));
1464        assert!(on_attested.has_teardown_firing_on(ProcessPhase::Attested));
1465        assert!(!on_failed.has_teardown_firing_on(ProcessPhase::Attested));
1466        // Failed is fired on by BOTH Always AND OnFailed.
1467        assert!(always.has_teardown_firing_on(ProcessPhase::Failed));
1468        assert!(on_failed.has_teardown_firing_on(ProcessPhase::Failed));
1469        assert!(!on_attested.has_teardown_firing_on(ProcessPhase::Failed));
1470    }
1471
1472    /// NON-TERMINAL-PHASE PIN — every non-terminal-gate
1473    /// [`ProcessPhase`] (`Pending` / `Forking` / `Execing` / `Running`
1474    /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` /
1475    /// `Reaped`) returns `false` for EVERY [`TeardownPolicy`],
1476    /// including [`TeardownPolicy::Always`]. Locks the terminal-only
1477    /// contract of the predicate so a regression that widened the
1478    /// firing-arm scope to a non-terminal phase (a hypothetical
1479    /// `Always fires on Running` misreading of "always") fails HERE at
1480    /// the substrate primitive.
1481    #[test]
1482    fn ephemeral_lifetime_has_teardown_firing_on_returns_false_on_non_terminal_phases_for_every_policy(
1483    ) {
1484        for policy in TeardownPolicy::ALL {
1485            let eph = EphemeralLifetime {
1486                teardown_policy: policy,
1487                ..EphemeralLifetime::default()
1488            };
1489            for phase in ProcessPhase::ALL {
1490                if matches!(phase, ProcessPhase::Attested | ProcessPhase::Failed) {
1491                    continue;
1492                }
1493                assert!(
1494                    !eph.has_teardown_firing_on(phase),
1495                    "policy={policy:?} must not fire on non-terminal phase={phase:?}",
1496                );
1497            }
1498        }
1499    }
1500
1501    #[test]
1502    fn serde_round_trip_ephemeral() {
1503        // Routes through the ONE substrate composer
1504        // [`Lifetime::ephemeral`] — see the composer's doc-comment for
1505        // the full migration rationale.
1506        let l = Lifetime::ephemeral(EphemeralLifetime {
1507            ttl: "30m".into(),
1508            teardown_policy: TeardownPolicy::OnAttested,
1509            max_concurrent: 4,
1510            exports: vec![],
1511        });
1512        let yaml = serde_yaml::to_string(&l).unwrap();
1513        assert!(yaml.contains("ttl: 30m"));
1514        assert!(yaml.contains("teardownPolicy: OnAttested"));
1515        // Empty exports skip-serialize — explicit zero-trace default.
1516        assert!(!yaml.contains("exports"));
1517        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
1518        assert!(back.is_ephemeral());
1519        assert!(back.ephemeral.unwrap().exports.is_empty());
1520    }
1521
1522    #[test]
1523    fn applicable_exports_filters_by_trigger() {
1524        use crate::export::{
1525            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
1526            VectorChannel,
1527        };
1528        let spec_attested = ExportSpec {
1529            source: ArtifactSource {
1530                receipts: Some(ReceiptsSource::default()),
1531                ..ArtifactSource::default()
1532            },
1533            channel: VectorChannel {
1534                http_event: Some(HttpEventChannel::signal("receipt")),
1535                ..VectorChannel::default()
1536            },
1537            when: ExportTrigger::OnAttested,
1538            experiment_id_override: None,
1539        };
1540        let spec_failed = ExportSpec {
1541            when: ExportTrigger::OnFailed,
1542            ..spec_attested.clone()
1543        };
1544        let spec_always = ExportSpec {
1545            when: ExportTrigger::Always,
1546            ..spec_attested.clone()
1547        };
1548
1549        let lt = EphemeralLifetime {
1550            ttl: "1h".into(),
1551            teardown_policy: TeardownPolicy::OnAttested,
1552            max_concurrent: 1,
1553            exports: vec![spec_attested, spec_failed, spec_always],
1554        };
1555
1556        // Attested gate fires OnAttested + Always — 2 of 3.
1557        assert!(lt.has_applicable_exports(ProcessPhase::Attested));
1558        assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
1559
1560        // Failed gate fires OnFailed + Always — 2 of 3.
1561        assert!(lt.has_applicable_exports(ProcessPhase::Failed));
1562        assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
1563
1564        // Other phases never route through Releasing.
1565        for p in [
1566            ProcessPhase::Pending,
1567            ProcessPhase::Forking,
1568            ProcessPhase::Execing,
1569            ProcessPhase::Running,
1570            ProcessPhase::Reconverging,
1571            ProcessPhase::Releasing,
1572            ProcessPhase::Exiting,
1573            ProcessPhase::Zombie,
1574            ProcessPhase::Reaped,
1575        ] {
1576            assert!(!lt.has_applicable_exports(p));
1577            assert_eq!(lt.applicable_exports(p).count(), 0);
1578        }
1579    }
1580
1581    #[test]
1582    fn no_exports_means_no_applicable_exports() {
1583        let lt = EphemeralLifetime::default();
1584        assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
1585        assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
1586    }
1587
1588    /// Structural well-formedness of [`LifetimeKind`] as a
1589    /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-
1590    /// wide testkit that pins ALL structural invariants (`ALL` is
1591    /// non-empty, every variant round-trips through `label ↔
1592    /// parse_label`, labels are pairwise distinct, `""` is outside
1593    /// the closed set, the [`UnknownLifetimeKind`] carrier's Display
1594    /// renders the substrate-wide `"unknown lifetime kind: <input>"`
1595    /// shape, `labels()` equals the natural `ALL × label` projection)
1596    /// at ONE call site. Subsumes the hand-derived
1597    /// `lifetime_kind_all_is_unique_and_complete` sweep the pre-derive
1598    /// site published — clauses (1)+(3) of the testkit fold uniqueness
1599    /// + non-emptiness into the substrate primitive's own body.
1600    #[test]
1601    fn lifetime_kind_is_well_formed_closed_set() {
1602        tatara_closed_set::assert_closed_set_well_formed::<LifetimeKind>();
1603    }
1604
1605    /// The Display impl IS `as_str` — pinning this lets future callers
1606    /// reach for either projection without drift. Symmetric to every
1607    /// sibling `X_display_matches_as_str` invariant across
1608    /// `tatara-process`; routes through the substrate primitive
1609    /// [`crate::tagged_union::assert_display_matches_label`] shared
1610    /// with all 29+ production Display-alignment sites. The auto-
1611    /// derived `Display` body from `#[closed_set(via = "as_str",
1612    /// display)]` emits the substrate-wide `f.write_str(Self::as_str
1613    /// (*self))` shape — a regression that regresses `as_str` (or a
1614    /// future hand-rolled Display block that drifts from `as_str`)
1615    /// surfaces here at the substrate-wide alignment probe.
1616    #[test]
1617    fn lifetime_kind_display_matches_as_str() {
1618        crate::tagged_union::assert_display_matches_label::<LifetimeKind>();
1619    }
1620
1621    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
1622    /// camelCase serde field name on `Lifetime`. A future rename of
1623    /// any field lands here at one site — and the wire-key alignment
1624    /// stays coherent with the operator-facing serde shape.
1625    ///
1626    /// Routes through the substrate primitive
1627    /// [`crate::tagged_union::assert_wire_key_matches_label`] — the
1628    /// bound-relaxed peer of `assert_single_slot_key_matches_label`
1629    /// that drops the `T: TaggedUnion` requirement so `Lifetime`
1630    /// (whose empty variant resolves to `Permanent(&DEFAULT_PERMANENT)`
1631    /// rather than to a [`crate::tagged_union::TaggedUnionError::empty`]
1632    /// carrier) still binds through ONE substrate wire-key alignment
1633    /// site. Pre-lift the body restated the same serialize +
1634    /// exactly-one-key + name-equality sweep at this test surface
1635    /// verbatim; post-lift the projection lives at ONE substrate
1636    /// primitive and this site binds through a single call — the
1637    /// same mechanical shape the four sibling TaggedUnion parents
1638    /// carry via the trait-projected [`crate::tagged_union::assert_single_slot_key_matches_label`].
1639    #[test]
1640    fn lifetime_kind_as_str_matches_lifetime_field_name() {
1641        crate::tagged_union::assert_wire_key_matches_label::<Lifetime, LifetimeKind, _>(
1642            single_slot_lifetime,
1643        );
1644    }
1645
1646    /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
1647    /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
1648    /// projection composes the closed set in both directions — a
1649    /// regression that misroutes a select arm (e.g. `Self::Permanent =>
1650    /// l.ephemeral.as_ref()...`) fails loudly here.
1651    #[test]
1652    fn lifetime_kind_round_trips_through_variant_kind() {
1653        for kind in LifetimeKind::ALL {
1654            let l = single_slot_lifetime(kind);
1655            let v = kind.select(&l).expect("populated slot must select");
1656            assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
1657            // And the resolver lands on the same variant.
1658            assert_eq!(
1659                l.variant().expect("exactly-one variant").kind(),
1660                kind,
1661                "variant() resolver disagreed on {kind:?}"
1662            );
1663        }
1664    }
1665
1666    // ─── Lifetime::has substrate pins ────────────────────────────────
1667    //
1668    // Fail-before-pass-after granularity: the `Lifetime::has` inherent
1669    // method did not exist before this commit, so each test below
1670    // fails to compile pre-lift. Post-lift they collectively pin the
1671    // (POPULATED slot × closed-set discriminator) presence-probe shape
1672    // at ONE substrate primitive on `Lifetime` — a regression that
1673    // drifted `has` from `kind.select(self).is_some()` (e.g. a swap to
1674    // "resolver picks kind" semantics that would silently promote
1675    // `Lifetime::default().has(Permanent)` from `false` to `true`)
1676    // surfaces HERE rather than as caller-side skew across the
1677    // `is_ephemeral` delegator + every future `lifetime-<kind>`
1678    // requires-tag consumer.
1679
1680    /// POPULATED-slot semantics pin: `has(kind)` returns `true` iff
1681    /// the slot addressed by `kind` is `Some(_)`. Sweeps every
1682    /// [`LifetimeKind::ALL`] entry against a single-slot fixture on
1683    /// the diagonal (populated slot AND matching kind → `true`) and
1684    /// off the diagonal (populated slot BUT other kind → `false`).
1685    /// Byte-shape parity with the pre-lift `self.<field>.is_some()`
1686    /// probe [`Lifetime::is_ephemeral`] walked and with the sibling
1687    /// [`crate::intent::Intent::has`] shape on `ProcessSpec`.
1688    #[test]
1689    fn lifetime_has_returns_true_on_diagonal_and_false_off_diagonal() {
1690        for populated in LifetimeKind::ALL {
1691            let l = single_slot_lifetime(populated);
1692            for probed in LifetimeKind::ALL {
1693                let expected = probed == populated;
1694                assert_eq!(
1695                    l.has(probed),
1696                    expected,
1697                    "Lifetime::has drift — populated={populated:?} probed={probed:?} expected={expected}",
1698                );
1699            }
1700        }
1701    }
1702
1703    /// SUBSTRATE-DELEGATION pin: `has(kind)` matches
1704    /// `kind.select(self).is_some()` byte-for-byte across every
1705    /// [`LifetimeKind::ALL`] entry and every diagonal / off-diagonal
1706    /// input. A regression that specialized `has` (a hand-rolled
1707    /// per-variant match block that drifts from the closed-set-driven
1708    /// `select` dispatcher, an early-return that short-circuits ambiguity
1709    /// checks a future `Lifetime::variant`-resolver refinement would
1710    /// need) surfaces HERE rather than as silent per-consumer drift.
1711    #[test]
1712    fn lifetime_has_matches_kind_select_is_some_bytewise() {
1713        for populated in LifetimeKind::ALL {
1714            let l = single_slot_lifetime(populated);
1715            for probed in LifetimeKind::ALL {
1716                assert_eq!(
1717                    l.has(probed),
1718                    probed.select(&l).is_some(),
1719                    "Lifetime::has drifted from kind.select(self).is_some() for populated={populated:?} probed={probed:?}",
1720                );
1721            }
1722        }
1723    }
1724
1725    /// EMPTY-lifetime pin: a [`Lifetime`] with both slots [`None`]
1726    /// returns `false` for EVERY [`LifetimeKind`] — even though
1727    /// [`Lifetime::variant`] would resolve it to `Ok(Permanent)` via
1728    /// the default fallback. The two probes answer distinct questions
1729    /// (POPULATED slot vs RESOLVED variant); the pin binds the
1730    /// POPULATED semantic so a future consumer that reaches for
1731    /// `has(Permanent)` on a default lifetime hits the operator-visible
1732    /// "no permanent slot stamped" answer rather than the resolver's
1733    /// "empty defaults to Permanent" answer.
1734    #[test]
1735    fn lifetime_has_returns_false_on_default_lifetime_for_every_kind() {
1736        let l = Lifetime::default();
1737        for kind in LifetimeKind::ALL {
1738            assert!(
1739                !l.has(kind),
1740                "default Lifetime has no slot populated, yet has({kind:?}) returned true",
1741            );
1742        }
1743        // Sanity: the resolver still picks Permanent on the empty
1744        // input. If this changed, the semantics on the two probes
1745        // would diverge and the pin above would need re-thinking.
1746        assert_eq!(
1747            l.variant().expect("default resolves").kind(),
1748            LifetimeKind::Permanent,
1749        );
1750    }
1751
1752    /// AMBIGUOUS-lifetime pin: a [`Lifetime`] with BOTH slots
1753    /// [`Some`] returns `true` for EVERY [`LifetimeKind`] — the
1754    /// POPULATED probe answers per-slot independently and does NOT
1755    /// short-circuit through the resolver's ambiguity error. The two
1756    /// probes answer distinct questions (POPULATED slot vs RESOLVED
1757    /// variant); [`Lifetime::variant`] on the same input errors with
1758    /// [`LifetimeError::Ambiguous`], while `has` reports both slots
1759    /// stamped. A future consumer that wants "did the operator stamp
1760    /// this slot" (an audit binary flagging both-slot Processes for
1761    /// migration) reaches through `has`; a consumer that wants "did
1762    /// the resolver settle on this kind" composes through `variant`.
1763    #[test]
1764    fn lifetime_has_returns_true_on_ambiguous_lifetime_for_every_populated_kind() {
1765        let l = Lifetime {
1766            permanent: Some(PermanentLifetime {}),
1767            ephemeral: Some(EphemeralLifetime::default()),
1768        };
1769        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1770        for kind in LifetimeKind::ALL {
1771            assert!(
1772                l.has(kind),
1773                "ambiguous Lifetime has both slots populated, yet has({kind:?}) returned false",
1774            );
1775        }
1776    }
1777
1778    /// DELEGATION pin: [`Lifetime::is_ephemeral`] delegates through
1779    /// [`Lifetime::has`]`(LifetimeKind::Ephemeral)` byte-for-byte across
1780    /// every representative input (empty, permanent-only, ephemeral-
1781    /// only, ambiguous). A regression that reintroduces the pre-lift
1782    /// `self.ephemeral.is_some()` inline body (breaking the delegation
1783    /// chain to the substrate primitive) would silently succeed
1784    /// bytewise TODAY — but would strand [`Lifetime::is_ephemeral`]
1785    /// out of every future normalization landing at [`Self::has`]
1786    /// (a widened return, a debug-build assertion, a per-fleet warn).
1787    /// This pin binds the delegation so the divergence surfaces HERE
1788    /// rather than as silent drift downstream.
1789    #[test]
1790    fn is_ephemeral_delegates_through_has_ephemeral_bytewise() {
1791        let inputs: [Lifetime; 4] = [
1792            Lifetime::default(),
1793            Lifetime::permanent(),
1794            Lifetime::ephemeral(EphemeralLifetime::default()),
1795            Lifetime {
1796                permanent: Some(PermanentLifetime {}),
1797                ephemeral: Some(EphemeralLifetime::default()),
1798            },
1799        ];
1800        for l in &inputs {
1801            assert_eq!(
1802                l.is_ephemeral(),
1803                l.has(LifetimeKind::Ephemeral),
1804                "is_ephemeral() drifted from has(Ephemeral) for {l:?}",
1805            );
1806        }
1807    }
1808
1809    /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
1810    /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
1811    /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
1812    /// previously hand-rolled.
1813    #[test]
1814    fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
1815        let permanent = PermanentLifetime {};
1816        let v = LifetimeVariant::Permanent(&permanent);
1817        assert!(v.as_ephemeral().is_none());
1818        assert!(v.as_permanent().is_some());
1819
1820        let ephemeral = EphemeralLifetime {
1821            ttl: "42m".into(),
1822            teardown_policy: TeardownPolicy::OnAttested,
1823            max_concurrent: 3,
1824            exports: vec![],
1825        };
1826        let v = LifetimeVariant::Ephemeral(&ephemeral);
1827        let inner = v.as_ephemeral().expect("ephemeral must project");
1828        assert_eq!(inner.ttl, "42m");
1829        assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
1830        assert_eq!(inner.max_concurrent, 3);
1831        assert!(v.as_permanent().is_none());
1832    }
1833
1834    /// `Lifetime::resolved_ephemeral` — the compound-lift primitive that
1835    /// composes `variant().ok() + as_ephemeral` — projects to `Some(&e)`
1836    /// iff the resolver picks the ephemeral slot unambiguously. All
1837    /// three failure modes (empty → permanent default, permanent-only,
1838    /// ambiguous) collapse to `None`, matching the pre-lift
1839    /// `lifetime_clock::evaluate` + `requeue_with_ttl` "no ephemeral
1840    /// action" outcome (`AutoTerminate::Skip` / `default` requeue).
1841    ///
1842    /// The ambiguous → `None` arm is DELIBERATELY the same outcome as
1843    /// permanent-only: an operator-authored spec with both slots
1844    /// populated is a mis-configuration, and firing TTL / teardown on
1845    /// it would be worse than skipping. Pinning that collapse here
1846    /// closes the possibility of a future per-consumer drift where one
1847    /// branch honors ambiguity (fires the timed action) and another
1848    /// doesn't.
1849    ///
1850    /// The `Some` arm asserts byte-identity of the projected borrow
1851    /// against `self.ephemeral.as_ref().unwrap()` — a mis-wire that
1852    /// silently swapped the projection to `self.permanent.as_ref()`
1853    /// would surface here as a type mismatch rather than as a runtime
1854    /// no-op in production.
1855    #[test]
1856    fn resolved_ephemeral_projects_only_the_unambiguous_ephemeral_slot() {
1857        // 1. Empty (both slots None) — resolves to Permanent default.
1858        let l = Lifetime::default();
1859        assert!(l.resolved_ephemeral().is_none());
1860
1861        // 2. Permanent-only.
1862        // Routes through the ONE substrate composer
1863        // [`Lifetime::permanent`] — see the composer's doc-comment.
1864        let l = Lifetime::permanent();
1865        assert!(l.resolved_ephemeral().is_none());
1866
1867        // 3. Ephemeral-only — the ONE arm that projects.
1868        let ephemeral = EphemeralLifetime {
1869            ttl: "13m".into(),
1870            teardown_policy: TeardownPolicy::OnFailed,
1871            max_concurrent: 7,
1872            exports: vec![],
1873        };
1874        // Routes through the ONE substrate composer
1875        // [`Lifetime::ephemeral`] — see the composer's doc-comment.
1876        let l = Lifetime::ephemeral(ephemeral.clone());
1877        let e = l.resolved_ephemeral().expect("ephemeral-only must project");
1878        assert_eq!(e.ttl, "13m");
1879        assert_eq!(e.teardown_policy, TeardownPolicy::OnFailed);
1880        assert_eq!(e.max_concurrent, 7);
1881        // The borrow points into `self.ephemeral`, not into a temporary.
1882        assert!(std::ptr::eq(e, l.ephemeral.as_ref().unwrap()));
1883
1884        // 4. Ambiguous (both slots set) — collapses to None, NOT to
1885        //    the ephemeral inner. Guards against a future refactor
1886        //    that silently unwrapped ambiguity to "prefer ephemeral".
1887        let l = Lifetime {
1888            permanent: Some(PermanentLifetime {}),
1889            ephemeral: Some(EphemeralLifetime::default()),
1890        };
1891        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1892        assert!(l.resolved_ephemeral().is_none());
1893    }
1894
1895    /// EMPTY-SLICE CONTRACT on [`Lifetime::ephemeral_exports`]: every
1896    /// non-Ephemeral outcome of the [`Lifetime::variant`] resolver
1897    /// (Permanent-only, empty-default, Ambiguous-both-slots) projects
1898    /// to the empty slice, byte-identical with `&[][..]`. Pin the
1899    /// three outcomes explicitly so a future refactor that promoted
1900    /// any of them to a non-empty projection (e.g. leaking a stashed
1901    /// default `EphemeralLifetime` on the Ambiguous corner) surfaces
1902    /// here instead of at every `export-<kind>` require-tag callsite
1903    /// as silent presence-flip.
1904    #[test]
1905    fn ephemeral_exports_returns_empty_slice_on_permanent_default_and_ambiguous_lifetime() {
1906        // 1. Empty (both slots None) — resolves to Permanent default.
1907        let l = Lifetime::default();
1908        assert!(l.ephemeral_exports().is_empty());
1909        // Byte-identity with `&[]` — not merely `len() == 0`.
1910        let empty: &[crate::export::ExportSpec] = &[];
1911        assert_eq!(l.ephemeral_exports().as_ptr(), empty.as_ptr());
1912
1913        // 2. Permanent-only.
1914        let l = Lifetime::permanent();
1915        assert!(l.ephemeral_exports().is_empty());
1916
1917        // 3. Ambiguous (both slots set) — variant() = Err, so the
1918        //    resolver's Option collapse yields None and the slice
1919        //    projection returns `&[]`. Guards against a future refactor
1920        //    that silently promoted the ambiguous corner to project
1921        //    the ephemeral half's exports through.
1922        let l = Lifetime {
1923            permanent: Some(PermanentLifetime {}),
1924            ephemeral: Some(EphemeralLifetime {
1925                exports: vec![minimal_export_spec()],
1926                ..EphemeralLifetime::default()
1927            }),
1928        };
1929        assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
1930        assert!(
1931            l.ephemeral_exports().is_empty(),
1932            "ambiguous lifetime must NOT project the ephemeral half's exports",
1933        );
1934    }
1935
1936    /// POPULATED-PROJECTION CONTRACT on [`Lifetime::ephemeral_exports`]:
1937    /// when the resolver unambiguously projects to `Ephemeral`, the
1938    /// returned slice is the ephemeral's `exports` vec by byte-shape
1939    /// — length matches, every entry compares equal, and the slice's
1940    /// pointer aims into `self.ephemeral.as_ref().unwrap().exports`
1941    /// (not into a temporary). Composes with every
1942    /// [`ExportSpecSliceExt`] method the caller may drape onto the
1943    /// projection.
1944    #[test]
1945    fn ephemeral_exports_returns_declared_exports_slice_on_ephemeral_lifetime() {
1946        use crate::export::ExportTrigger;
1947
1948        // Empty exports on the Ephemeral arm — projection returns
1949        // `&[]` from the ephemeral's own `.exports.as_slice()`.
1950        let l = Lifetime::ephemeral(EphemeralLifetime {
1951            exports: vec![],
1952            ..EphemeralLifetime::default()
1953        });
1954        assert!(l.ephemeral_exports().is_empty());
1955
1956        // Populated — projection returns the declared exports.
1957        let exports = vec![minimal_export_spec(), {
1958            let mut e = minimal_export_spec();
1959            e.when = ExportTrigger::OnFailed;
1960            e
1961        }];
1962        let l = Lifetime::ephemeral(EphemeralLifetime {
1963            exports: exports.clone(),
1964            ..EphemeralLifetime::default()
1965        });
1966        let slice = l.ephemeral_exports();
1967        assert_eq!(slice.len(), exports.len());
1968        for (i, expected) in exports.iter().enumerate() {
1969            assert_eq!(slice[i].when, expected.when);
1970        }
1971    }
1972
1973    /// POINTER-IDENTITY CONTRACT on [`Lifetime::ephemeral_exports`]:
1974    /// the returned slice points into `self.ephemeral.as_ref().
1975    /// unwrap().exports`, not into a temporary. A mis-wire that
1976    /// silently swapped the projection to a fresh Vec (e.g. through
1977    /// `.to_vec()`) would surface here as a pointer-mismatch, before
1978    /// any behavior-preservation invariant on downstream
1979    /// [`ExportSpecSliceExt`] callers could hide it. Composes with
1980    /// the peer contract on [`Self::resolved_ephemeral`]'s
1981    /// `std::ptr::eq(e, l.ephemeral.as_ref().unwrap())` pin.
1982    #[test]
1983    fn ephemeral_exports_slice_matches_resolved_ephemeral_exports_pointer_when_present() {
1984        let l = Lifetime::ephemeral(EphemeralLifetime {
1985            exports: vec![minimal_export_spec()],
1986            ..EphemeralLifetime::default()
1987        });
1988        let via_slice_ptr = l.ephemeral_exports().as_ptr();
1989        let via_resolver_ptr = l
1990            .resolved_ephemeral()
1991            .expect("ephemeral-only must project")
1992            .exports
1993            .as_ptr();
1994        assert!(std::ptr::eq(via_slice_ptr, via_resolver_ptr));
1995    }
1996
1997    /// Minimal well-formed [`crate::export::ExportSpec`] for the
1998    /// three [`Lifetime::ephemeral_exports`] tests above. Composed
1999    /// through the smallest closed-set / tagged-union arms on the
2000    /// export surface (`OnAttested` trigger, receipts-only source,
2001    /// HTTP-event-only channel) so the fixture's identity comparisons
2002    /// stay stable against future variant additions on the peer
2003    /// discriminators.
2004    fn minimal_export_spec() -> crate::export::ExportSpec {
2005        use crate::export::{
2006            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
2007            VectorChannel,
2008        };
2009        ExportSpec {
2010            source: ArtifactSource {
2011                receipts: Some(ReceiptsSource::default()),
2012                ..ArtifactSource::default()
2013            },
2014            channel: VectorChannel {
2015                http_event: Some(HttpEventChannel::signal("receipt")),
2016                ..VectorChannel::default()
2017            },
2018            when: ExportTrigger::OnAttested,
2019            experiment_id_override: None,
2020        }
2021    }
2022
2023    /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
2024    /// set" outcome is `Permanent`, not an error. Pin via the
2025    /// closed-set kind projection so a future variant added to the
2026    /// closed set (and to the `Lifetime` struct) without updating
2027    /// the default resolution would surface here — the default
2028    /// stays `Permanent` regardless of the closed set's arity.
2029    #[test]
2030    fn empty_lifetime_resolves_to_permanent_kind() {
2031        let l = Lifetime::default();
2032        let v = l.variant().expect("default lifetime resolves");
2033        assert_eq!(v.kind(), LifetimeKind::Permanent);
2034        assert!(v.as_permanent().is_some());
2035        assert!(v.as_ephemeral().is_none());
2036    }
2037
2038    /// Construct a `Lifetime` with exactly the given kind's slot
2039    /// populated by a minimal valid inner spec. Shared across the
2040    /// closed-set property tests so they each cover every variant
2041    /// without restating the construction table.
2042    fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
2043        // Each arm routes through the ONE substrate composer for its
2044        // closed-set discriminator — [`Lifetime::permanent`] /
2045        // [`Lifetime::ephemeral`]. See their doc-comments for the
2046        // migration rationale.
2047        match kind {
2048            LifetimeKind::Permanent => Lifetime::permanent(),
2049            LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
2050        }
2051    }
2052
2053    #[test]
2054    fn exports_round_trip_through_lifetime() {
2055        use crate::export::{
2056            ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
2057            VectorChannel,
2058        };
2059        // Routes through the ONE substrate composer
2060        // [`Lifetime::ephemeral`] — see the composer's doc-comment.
2061        let l = Lifetime::ephemeral(EphemeralLifetime {
2062            ttl: "30m".into(),
2063            teardown_policy: TeardownPolicy::OnAttested,
2064            max_concurrent: 1,
2065            exports: vec![ExportSpec {
2066                source: ArtifactSource {
2067                    receipts: Some(ReceiptsSource::default()),
2068                    ..ArtifactSource::default()
2069                },
2070                channel: VectorChannel {
2071                    http_event: Some(HttpEventChannel::signal("receipt")),
2072                    ..VectorChannel::default()
2073                },
2074                when: ExportTrigger::OnAttested,
2075                experiment_id_override: None,
2076            }],
2077        });
2078        let yaml = serde_yaml::to_string(&l).unwrap();
2079        assert!(yaml.contains("exports:"));
2080        assert!(yaml.contains("receipts: {}"));
2081        assert!(yaml.contains("signalType: receipt"));
2082        let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
2083        let e = back.ephemeral.unwrap();
2084        assert_eq!(e.exports.len(), 1);
2085        assert!(e.exports[0].source.receipts.is_some());
2086        assert!(e.exports[0].channel.http_event.is_some());
2087    }
2088
2089    // ─── EphemeralLifetime::ttl_duration substrate pins ──────────────
2090    //
2091    // The `humantime::parse_duration(&<eph>.ttl).ok()` chain was open-
2092    // lifted from TWO consumer sites in `crate::lifetime_clock`
2093    // (`evaluate` + `requeue_with_ttl`) onto the ONE substrate
2094    // primitive [`EphemeralLifetime::ttl_duration`]. These pins bind
2095    // the primitive at the fail-before-pass-after level so a future
2096    // regression that swaps the return-form (an
2097    // `anyhow::Result<Duration>` — a per-consumer normalization gate
2098    // — a saturating `Duration::ZERO` for the parse-error corner)
2099    // fails HERE before landing at either consumer.
2100
2101    fn eph_with_ttl(ttl: &str) -> EphemeralLifetime {
2102        EphemeralLifetime {
2103            ttl: ttl.to_string(),
2104            teardown_policy: TeardownPolicy::default(),
2105            max_concurrent: 1,
2106            exports: Vec::new(),
2107        }
2108    }
2109
2110    /// The canonical shape every consumer rides through — a parseable
2111    /// humantime string projects to `Some(std::time::Duration)` matching
2112    /// the operator-authored `ttl` verbatim. Pin: the returned duration
2113    /// is EXACTLY what `humantime::parse_duration` produces for the
2114    /// same input, in `std::time::Duration` so the downstream
2115    /// `elapsed >= ttl` / `ttl.checked_sub(elapsed)` comparators land
2116    /// with both operands on the same axis without a per-consumer
2117    /// conversion.
2118    #[test]
2119    fn ttl_duration_parseable_humantime_projects_to_some() {
2120        for (ttl, expected) in [
2121            ("1h", std::time::Duration::from_secs(3600)),
2122            ("30m", std::time::Duration::from_secs(1800)),
2123            ("90s", std::time::Duration::from_secs(90)),
2124            ("5m30s", std::time::Duration::from_secs(330)),
2125            ("500ms", std::time::Duration::from_millis(500)),
2126        ] {
2127            assert_eq!(
2128                eph_with_ttl(ttl).ttl_duration(),
2129                Some(expected),
2130                "ttl_duration drift for {ttl:?}",
2131            );
2132        }
2133    }
2134
2135    /// The `None` arm is the "operator's ttl string doesn't parse"
2136    /// corner every consumer collapses to the skip-branch — a typo, an
2137    /// unsupported unit, an empty string, a free-form label that
2138    /// reached the field. Pin the boundary at the primitive so a
2139    /// future normalization can't silently substitute a default in
2140    /// place of the parse-failure signal.
2141    #[test]
2142    fn ttl_duration_unparseable_returns_none() {
2143        for bad in [
2144            // Empty string — the operator left the ttl blank.
2145            "", // Non-humantime literal — the operator wrote a foreign format.
2146            "forever", "1our",
2147            // Nonsense that looks numeric but isn't a humantime span.
2148            "abc",
2149            // Only-whitespace input — passes serde's non-empty gate but
2150            // doesn't parse as a duration.
2151            "   ",
2152        ] {
2153            assert_eq!(
2154                eph_with_ttl(bad).ttl_duration(),
2155                None,
2156                "ttl_duration should be None for {bad:?}",
2157            );
2158        }
2159    }
2160
2161    /// Zero-duration edge — `"0s"` parses to `Duration::ZERO`, not
2162    /// `None`. Every consumer needs the zero-ttl EphemeralLifetime to
2163    /// count as "elapsed=0 already ≥ ttl=0" so a zero-TTL ephemeral
2164    /// expires on its own creation instant; swapping this arm to
2165    /// `None` would silently keep every zero-TTL Process alive past
2166    /// the TTL-expiry gate in [`crate::lifetime_clock::evaluate`].
2167    #[test]
2168    fn ttl_duration_zero_seconds_returns_some_zero() {
2169        assert_eq!(
2170            eph_with_ttl("0s").ttl_duration(),
2171            Some(std::time::Duration::ZERO),
2172        );
2173    }
2174
2175    /// Subsecond precision survives the parse — a regression that
2176    /// silently truncated to whole seconds would compare
2177    /// `elapsed = 500ms` against a `ttl_duration()` of `500ms` as
2178    /// `500ms >= 0s` (always fire) rather than `500ms >= 500ms`
2179    /// (fires on the boundary). Peer to the sibling substrate
2180    /// `elapsed_since` subsecond pin in `crate::time`.
2181    #[test]
2182    fn ttl_duration_preserves_subsecond_precision() {
2183        assert_eq!(
2184            eph_with_ttl("250ms").ttl_duration(),
2185            Some(std::time::Duration::from_millis(250)),
2186        );
2187    }
2188
2189    /// Default `EphemeralLifetime` carries the canonical `"1h"` ttl
2190    /// (matches `default_ttl()` at this file's top), so
2191    /// `.ttl_duration()` on it agrees with a manually-parsed `"1h"`
2192    /// pass through `humantime`. Pins the default-ttl contract at
2193    /// the substrate so a future default rename lands at ONE site
2194    /// (this ttl_duration pin + the `default_ttl` fn) without silent
2195    /// wall-clock drift at either consumer.
2196    #[test]
2197    fn ttl_duration_of_default_ephemeral_matches_1h() {
2198        let e = EphemeralLifetime::default();
2199        assert_eq!(e.ttl, "1h");
2200        assert_eq!(e.ttl_duration(), Some(std::time::Duration::from_secs(3600)));
2201    }
2202
2203    /// Byte-for-byte parity with the pre-lift hand-authored chain —
2204    /// `<eph>.ttl_duration()` produces the SAME
2205    /// `Option<std::time::Duration>` as the two-link `humantime::
2206    /// parse_duration(&<eph>.ttl).ok()` chain both `lifetime_clock`
2207    /// consumers walked pre-lift. A regression at THIS pin fails
2208    /// before it lands at either consumer as silent operator-facing
2209    /// skew between the TTL-expiry gate and the sleep-budget picker.
2210    #[test]
2211    fn ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
2212        for ttl in [
2213            "1h", "30m", "90s", "5m30s", "500ms", "0s", "1us", "forever", "", "1our",
2214        ] {
2215            let e = eph_with_ttl(ttl);
2216            let via_primitive = e.ttl_duration();
2217            let hand_authored = humantime::parse_duration(&e.ttl).ok();
2218            assert_eq!(
2219                via_primitive, hand_authored,
2220                "ttl_duration must be byte-identical to `humantime::\
2221                 parse_duration(&self.ttl).ok()` for {ttl:?}",
2222            );
2223        }
2224    }
2225
2226    // ─── Lifetime::{permanent,ephemeral} substrate composer pins ─────
2227    //
2228    // The `Lifetime { permanent: Some(PermanentLifetime {}), .. }` +
2229    // `Lifetime { ephemeral: Some(<e>), .. }` shapes were open-lifted
2230    // from FOUR + ELEVEN+ hand-authored fixture literals onto the ONE
2231    // substrate composer pair [`Lifetime::permanent`] +
2232    // [`Lifetime::ephemeral`]. These pins bind the composers at the
2233    // fail-before-pass-after level so a future regression that flipped
2234    // either arm (a swapped slot assignment, a stray `Some` on the
2235    // opposite slot, a drift in the resolver's landing variant) fails
2236    // HERE before landing at any of the fifteen+ consumer sites.
2237
2238    /// Pre-lift the `Lifetime { permanent: Some(PermanentLifetime {}),
2239    /// ephemeral: None }` (equivalently `Lifetime { permanent:
2240    /// Some(PermanentLifetime {}), ..Lifetime::default() }`) shape had
2241    /// three surfaces every consumer paired: the resolver picks
2242    /// `Permanent`, the `is_ephemeral` gate reads `false`, and the
2243    /// two slots read as (`Some`, `None`). Bind all three from the
2244    /// composer in ONE assertion group.
2245    #[test]
2246    fn lifetime_permanent_composer_matches_pre_lift_shape_bytewise() {
2247        let via_primitive = Lifetime::permanent();
2248        let hand_authored = Lifetime {
2249            permanent: Some(PermanentLifetime {}),
2250            ephemeral: None,
2251        };
2252
2253        // Both slots read identically.
2254        assert!(via_primitive.permanent.is_some());
2255        assert!(hand_authored.permanent.is_some());
2256        assert!(via_primitive.ephemeral.is_none());
2257        assert!(hand_authored.ephemeral.is_none());
2258
2259        // is_default is false (permanent is set), is_ephemeral is false.
2260        assert!(!via_primitive.is_default());
2261        assert!(!via_primitive.is_ephemeral());
2262        assert_eq!(via_primitive.is_default(), hand_authored.is_default());
2263        assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
2264
2265        // Resolver lands on `Permanent`, not on `Ambiguous`.
2266        assert_eq!(
2267            via_primitive
2268                .variant()
2269                .expect("permanent-only resolves")
2270                .kind(),
2271            LifetimeKind::Permanent,
2272        );
2273
2274        // resolved_ephemeral projects to None (peer arm of the
2275        // ephemeral-only composer's Some(&e) landing).
2276        assert!(via_primitive.resolved_ephemeral().is_none());
2277    }
2278
2279    /// Pre-lift the `Lifetime { permanent: None, ephemeral: Some(<e>) }`
2280    /// (equivalently `Lifetime { ephemeral: Some(<e>), ..Lifetime::
2281    /// default() }`) shape had four surfaces every consumer paired: the
2282    /// resolver picks `Ephemeral(<e>)`, the `is_ephemeral` gate reads
2283    /// `true`, the two slots read as (`None`, `Some`), and
2284    /// [`Lifetime::resolved_ephemeral`] projects to `Some(&<e>)` with
2285    /// the SAME inner spec bytes the caller supplied. Bind all four
2286    /// from the composer in ONE assertion group.
2287    #[test]
2288    fn lifetime_ephemeral_composer_matches_pre_lift_shape_bytewise() {
2289        let inner = EphemeralLifetime {
2290            ttl: "17m".into(),
2291            teardown_policy: TeardownPolicy::OnFailed,
2292            max_concurrent: 5,
2293            exports: vec![],
2294        };
2295        let via_primitive = Lifetime::ephemeral(inner.clone());
2296        let hand_authored = Lifetime {
2297            permanent: None,
2298            ephemeral: Some(inner.clone()),
2299        };
2300
2301        // Both slots read identically.
2302        assert!(via_primitive.permanent.is_none());
2303        assert!(hand_authored.permanent.is_none());
2304        assert!(via_primitive.ephemeral.is_some());
2305        assert!(hand_authored.ephemeral.is_some());
2306
2307        // is_default is false, is_ephemeral is true.
2308        assert!(!via_primitive.is_default());
2309        assert!(via_primitive.is_ephemeral());
2310        assert_eq!(via_primitive.is_default(), hand_authored.is_default());
2311        assert_eq!(via_primitive.is_ephemeral(), hand_authored.is_ephemeral());
2312
2313        // Resolver lands on `Ephemeral` with the SAME inner bytes.
2314        let via_inner = via_primitive
2315            .resolved_ephemeral()
2316            .expect("ephemeral-only resolves to Some(&e)");
2317        assert_eq!(via_inner.ttl, inner.ttl);
2318        assert_eq!(via_inner.teardown_policy, inner.teardown_policy);
2319        assert_eq!(via_inner.max_concurrent, inner.max_concurrent);
2320
2321        // Kind projects to `Ephemeral`.
2322        assert_eq!(
2323            via_primitive
2324                .variant()
2325                .expect("ephemeral-only resolves")
2326                .kind(),
2327            LifetimeKind::Ephemeral,
2328        );
2329    }
2330
2331    /// The two composers PARTITION the closed set — every
2332    /// `LifetimeKind` variant is reachable by exactly ONE composer,
2333    /// and the composer's landing kind matches the discriminator. A
2334    /// future third variant added to `LifetimeKind` without a paired
2335    /// composer would surface here (the exhaustive `ALL` sweep would
2336    /// hit a case with no arm to construct through).
2337    #[test]
2338    fn lifetime_composers_cover_every_non_ambiguous_closed_set_arm() {
2339        for kind in LifetimeKind::ALL {
2340            let via_composer = match kind {
2341                LifetimeKind::Permanent => Lifetime::permanent(),
2342                LifetimeKind::Ephemeral => Lifetime::ephemeral(EphemeralLifetime::default()),
2343            };
2344            let resolved = via_composer
2345                .variant()
2346                .expect("composer output resolves unambiguously")
2347                .kind();
2348            assert_eq!(
2349                resolved, kind,
2350                "composer for {kind:?} must land on the SAME resolver kind",
2351            );
2352        }
2353    }
2354
2355    // ─── Workspace-canonical ephemeral-defaults substrate pins ────────
2356    //
2357    // Bind [`default_ephemeral_ttl`] + [`default_ephemeral_max_concurrent`]
2358    // and the paired [`DEFAULT_EPHEMERAL_TTL`] + [`DEFAULT_EPHEMERAL_MAX_CONCURRENT`]
2359    // consts at fail-before-pass-after granularity so a regression that
2360    // drifted the wire-form default (a shift from `"1h"` to `"30m"` at
2361    // ONLY the const, a shift from `1` to `2` at only the fn body, a
2362    // decoupling of the const from the fn's returned value) surfaces
2363    // HERE rather than as silent operator-visible skew across the
2364    // THREE serde-default consumers ([`EphemeralLifetime::ttl`] +
2365    // [`crate::ephemeral::EphemeralSpec::ttl`] + `tatara-reconciler
2366    // ::ephemeral_defaults::EphemeralDefaults::default_ttl`) that ride
2367    // through this ONE substrate owner.
2368
2369    #[test]
2370    fn default_ephemeral_ttl_matches_pre_lift_1h_string_bytewise() {
2371        // Byte-shape parity with the THREE hand-authored pre-lift shims
2372        // that each returned `"1h".to_string()`. A regression that
2373        // drifted the returned string (an accidental locale-specific
2374        // `"1 h"` spacing, a typo to `"1H"` that survives serde but
2375        // fails humantime parse, a shift to a whole-second `"3600s"`
2376        // canonicalization) fails HERE rather than at the three
2377        // downstream consumers.
2378        assert_eq!(
2379            default_ephemeral_ttl(),
2380            "1h",
2381            "default_ephemeral_ttl must return the pre-lift `\"1h\"` \
2382             string bytewise; a regression that drifted the wire-form \
2383             surfaces here rather than as three-way skew across the \
2384             EphemeralLifetime / EphemeralSpec / EphemeralDefaults \
2385             consumers.",
2386        );
2387    }
2388
2389    #[test]
2390    fn default_ephemeral_ttl_wire_form_const_matches_fn_return_bytewise() {
2391        // Cross-form coherence pin: the `pub const DEFAULT_EPHEMERAL_TTL:
2392        // &str` handle and the `pub fn default_ephemeral_ttl() -> String`
2393        // owner MUST project onto the SAME wire-form value. A regression
2394        // that updated one but not the other (e.g. lifted the const to
2395        // a new default but forgot the fn body, or vice versa) would
2396        // silently produce two divergent workspace-canonical defaults
2397        // — the const for compile-time consumers, the fn for serde-
2398        // default consumers. Pin the two projections at equality.
2399        assert_eq!(
2400            DEFAULT_EPHEMERAL_TTL, "1h",
2401            "DEFAULT_EPHEMERAL_TTL const must byte-match the pre-lift \
2402             wire-form default `\"1h\"`",
2403        );
2404        assert_eq!(
2405            default_ephemeral_ttl(),
2406            DEFAULT_EPHEMERAL_TTL,
2407            "default_ephemeral_ttl() must byte-match the paired \
2408             DEFAULT_EPHEMERAL_TTL const — a divergence would silently \
2409             skew serde-default consumers vs compile-time const readers",
2410        );
2411    }
2412
2413    #[test]
2414    fn default_ephemeral_ttl_composes_at_ephemeral_lifetime_default_field() {
2415        // End-to-end: the `EphemeralLifetime::default()` composer routes
2416        // its `ttl:` slot through the substrate owner and the resulting
2417        // field reads bytewise-identical to the substrate primitive's
2418        // return. A regression that reintroduced a hand-authored `"1h"
2419        // .to_string()` inline at `Default::default` (or drifted the
2420        // ttl slot away from the substrate) would fail here.
2421        assert_eq!(
2422            EphemeralLifetime::default().ttl,
2423            default_ephemeral_ttl(),
2424            "EphemeralLifetime::default().ttl must route through \
2425             default_ephemeral_ttl — inline `\"1h\".to_string()` reintroduction \
2426             surfaces here rather than as skew with the two peer \
2427             ephemeral-defaults consumers",
2428        );
2429    }
2430
2431    #[test]
2432    fn default_ephemeral_ttl_composes_at_ephemeral_spec_serde_default() {
2433        // The `(defephemeral …)` Lisp authoring surface's serde default
2434        // must round-trip through the substrate owner: a YAML fragment
2435        // that omits the `ttl:` field parses into an EphemeralSpec whose
2436        // `ttl` reads bytewise-identical to the substrate primitive.
2437        // A regression that reintroduced a private `default_ttl` shim
2438        // in `ephemeral.rs` (bypassing the substrate) would produce a
2439        // silent skew between `defephemeral :ttl <omitted>` and
2440        // `EphemeralLifetime::default()`.
2441        let yaml = "\
2442aplicacao:
2443  chartRef: oci://ghcr.io/pleme-io/charts/lareira-demo-app
2444  version: \"0.5.5\"
2445  profile: all-in-one
2446  valuesOverlay: null
2447";
2448        let spec: crate::ephemeral::EphemeralSpec =
2449            serde_yaml::from_str(yaml).expect("EphemeralSpec YAML parses");
2450        assert_eq!(
2451            spec.ttl,
2452            default_ephemeral_ttl(),
2453            "EphemeralSpec serde-default for the omitted `ttl:` slot \
2454             must route through crate::lifetime::default_ephemeral_ttl \
2455             — a private-shim reintroduction skews the two peer \
2456             ephemeral-authoring surfaces silently",
2457        );
2458    }
2459
2460    #[test]
2461    fn default_ephemeral_ttl_parses_as_humantime_1h() {
2462        // Substrate invariant: the wire-form default MUST parse as a
2463        // humantime duration equal to one hour. A regression that
2464        // drifted the const to an unparseable string (a locale-specific
2465        // spelling, a serde-friendly-but-humantime-invalid literal)
2466        // would silently break every downstream TTL-expiry gate. Pin
2467        // the invariant at the substrate boundary rather than at every
2468        // consumer's own humantime-parse callsite.
2469        let parsed =
2470            humantime::parse_duration(DEFAULT_EPHEMERAL_TTL).expect("`\"1h\"` parses as humantime");
2471        assert_eq!(
2472            parsed,
2473            std::time::Duration::from_secs(3_600),
2474            "DEFAULT_EPHEMERAL_TTL must parse as a one-hour duration; a \
2475             regression that drifted the wire-form to an unparseable \
2476             string surfaces here rather than as silent TTL-expiry-gate \
2477             misfires at every downstream consumer",
2478        );
2479    }
2480
2481    #[test]
2482    fn default_ephemeral_max_concurrent_matches_pre_lift_1_bytewise() {
2483        // Byte-shape parity with the TWO hand-authored pre-lift shims
2484        // that each returned `1u32`. A regression that drifted the
2485        // returned value (a shift to `0` for uncapped, a shift to `2`
2486        // for parallel-safe defaults) surfaces here rather than at
2487        // both `EphemeralLifetime::max_concurrent` and
2488        // `EphemeralSpec::max_concurrent` serde defaults.
2489        assert_eq!(
2490            default_ephemeral_max_concurrent(),
2491            1,
2492            "default_ephemeral_max_concurrent must return the pre-lift \
2493             `1u32` value bytewise; a regression surfaces here rather \
2494             than as two-way skew across the EphemeralLifetime + \
2495             EphemeralSpec consumers.",
2496        );
2497    }
2498
2499    #[test]
2500    fn default_ephemeral_max_concurrent_wire_form_const_matches_fn_return_bytewise() {
2501        // Peer to the TTL cross-form pin — the `pub const
2502        // DEFAULT_EPHEMERAL_MAX_CONCURRENT: u32` handle and the
2503        // `pub fn default_ephemeral_max_concurrent() -> u32` owner MUST
2504        // project onto the SAME `1u32` value.
2505        assert_eq!(DEFAULT_EPHEMERAL_MAX_CONCURRENT, 1);
2506        assert_eq!(
2507            default_ephemeral_max_concurrent(),
2508            DEFAULT_EPHEMERAL_MAX_CONCURRENT,
2509        );
2510    }
2511
2512    #[test]
2513    fn default_ephemeral_max_concurrent_composes_at_ephemeral_lifetime_default_field() {
2514        // Same end-to-end contract as the TTL peer pin: the
2515        // `EphemeralLifetime::default()` composer routes its
2516        // `max_concurrent:` slot through the substrate owner and the
2517        // resulting field bytewise-matches the substrate primitive.
2518        assert_eq!(
2519            EphemeralLifetime::default().max_concurrent,
2520            default_ephemeral_max_concurrent(),
2521        );
2522    }
2523
2524    #[test]
2525    fn ephemeral_authoring_surfaces_share_workspace_canonical_ttl_default() {
2526        // Cross-surface coherence pin: the two `tatara-process`
2527        // ephemeral authoring surfaces — the lifetime-slot default and
2528        // the `(defephemeral …)` sugar default — MUST reach the SAME
2529        // workspace-canonical TTL string via serde default. A drift at
2530        // either site (a private-shim reintroduction, an inline literal
2531        // shortcut, a partial re-tuning that missed the peer) surfaces
2532        // HERE as observable skew between the two Default outputs.
2533        let yaml = "\
2534aplicacao:
2535  chartRef: oci://ghcr.io/pleme-io/charts/x
2536  version: \"0.1\"
2537  profile: p
2538  valuesOverlay: null
2539";
2540        let spec: crate::ephemeral::EphemeralSpec =
2541            serde_yaml::from_str(yaml).expect("EphemeralSpec parses");
2542        let lifetime = EphemeralLifetime::default();
2543        assert_eq!(
2544            spec.ttl, lifetime.ttl,
2545            "EphemeralSpec::ttl serde default and \
2546             EphemeralLifetime::default().ttl MUST agree — both must \
2547             route through crate::lifetime::default_ephemeral_ttl",
2548        );
2549        assert_eq!(
2550            spec.max_concurrent, lifetime.max_concurrent,
2551            "EphemeralSpec::max_concurrent serde default and \
2552             EphemeralLifetime::default().max_concurrent MUST agree — \
2553             both must route through crate::lifetime::default_ephemeral_max_concurrent",
2554        );
2555    }
2556
2557    /// Neither composer produces the ambiguous corner — the composer's
2558    /// contract is "exactly one slot set", and the ambiguous case
2559    /// [`LifetimeError::Ambiguous`] must be unreachable through them.
2560    /// A future refactor that widened either composer to accept the
2561    /// opposite slot (e.g. added a `permanent_with_ephemeral_override`
2562    /// arm) would surface here.
2563    #[test]
2564    fn lifetime_composers_never_produce_ambiguous_variant() {
2565        assert!(
2566            Lifetime::permanent().variant().is_ok(),
2567            "Lifetime::permanent must never resolve to Ambiguous",
2568        );
2569        assert!(
2570            Lifetime::ephemeral(EphemeralLifetime::default())
2571                .variant()
2572                .is_ok(),
2573            "Lifetime::ephemeral must never resolve to Ambiguous",
2574        );
2575    }
2576}