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