Skip to main content

tatara_process/
ephemeral.rs

1//! `EphemeralSpec` — the operator-facing typed surface for ephemeral
2//! Aplicacao installations.
3//!
4//! `EphemeralSpec` is *sugar* on top of `ProcessSpec`. The compounding move
5//! is to keep one wire format (`Process`, the Unix-process CRD) and let
6//! ephemeral envs be a Process with `:intent (:aplicacao …)` +
7//! `:lifetime (:ephemeral …)`. This struct gives that combination a
8//! dedicated `(defephemeral …)` keyword and a typed `From` bridge so
9//! authoring stays first-class without forking the CRD.
10//!
11//! Lisp authoring:
12//! ```lisp
13//! (defephemeral closed-loop-attest
14//!   :aplicacao  (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
15//!                :version "0.5.5"
16//!                :profile "all-in-one"
17//!                :values-overlay (:cluster (:name "ephemeral-test-01")
18//!                                 :persistence false))
19//!   :ttl        "1h"
20//!   :teardown   OnAttested
21//!   :postconditions
22//!     ((:kind HelmReleaseReleased
23//!       :params (:name "demo-app-consolidated"
24//!                :namespace "demo-test"))
25//!      (:kind ClosedLoopAuth
26//!       :params (:issuer (:service "demo-app-issuer" :port 8080)
27//!                :consumer (:service "demo-app-gateway" :port 8000)
28//!                :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
29//! ```
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use tatara_lisp::DeriveTataraDomain;
34
35use crate::boundary::{Boundary, Condition, ConditionKind, ConditionSliceExt};
36use crate::classification::Classification;
37use crate::crd::ProcessSpec;
38use crate::export::ExportSpec;
39use crate::intent::{AplicacaoIntent, Intent};
40use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
41use crate::routing::RoutingSpec;
42
43/// `EphemeralSpec` — typed wrapper that authors `(defephemeral …)`.
44///
45/// Lowers to a `ProcessSpec` via `From<EphemeralSpec>` — the bridge is
46/// pure-typed, no string substitution. Defaults to `point_type = Gate`,
47/// `substrate = Compute`, `data_classification = Internal` — every field
48/// can be overridden via the full `(defpoint …)` form when the operator
49/// needs the lower-level surface.
50#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "camelCase")]
52#[tatara(keyword = "defephemeral")]
53pub struct EphemeralSpec {
54    /// The Aplicacao chart + profile + overlay to install.
55    pub aplicacao: AplicacaoIntent,
56
57    /// TTL — `humantime` duration (`"1h"`, `"30m"`).
58    #[serde(default = "crate::lifetime::default_ephemeral_ttl")]
59    pub ttl: String,
60
61    /// When the ephemeral Process auto-terminates.
62    #[serde(default)]
63    pub teardown: TeardownPolicy,
64
65    /// Cluster-wide concurrency budget across ephemeral Processes sharing
66    /// the same `:aplicacao :chart-ref`. `0` = no cap.
67    #[serde(default = "crate::lifetime::default_ephemeral_max_concurrent")]
68    pub max_concurrent: u32,
69
70    /// Boundary postconditions evaluated before reaching `Attested`.
71    /// Typically `HelmReleaseReleased` plus one or more `ClosedLoopAuth`
72    /// / `JobAttested` checks for test suites + closed-loop probes.
73    #[serde(default)]
74    pub postconditions: Vec<Condition>,
75
76    /// Optional boundary preconditions (Namespace, Issuer, PullSecret
77    /// readiness etc.).
78    #[serde(default)]
79    pub preconditions: Vec<Condition>,
80
81    /// VERIFY-phase timeout. Empty = controller default.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub verify_timeout: Option<String>,
84
85    /// Optional Process classification override. When omitted, defaults
86    /// to `Gate / Compute / Internal / Bounded / NonMonotone`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub classification: Option<Classification>,
89
90    /// Optional parent PID path.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub parent: Option<String>,
93
94    /// Declared exports — sugar that propagates through to
95    /// `lifetime.ephemeral.exports` on the lowered `ProcessSpec`.
96    /// Default empty = zero-trace ephemeral (nothing survives
97    /// teardown). See [`crate::export`] for the full type.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub exports: Vec<ExportSpec>,
100
101    /// Routing template — DNS + Ingress declarations inherited by
102    /// the materialized `ProcessSpec`. When set on a pool's
103    /// `template`, every member receives the same shape; each
104    /// member's content-hash form differs by its own canonical
105    /// spec (which differs across members by slot index).
106    /// See [`crate::routing`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub routing: Option<RoutingSpec>,
109}
110
111// `default_ttl` + `default_max_concurrent` bindings for the two serde
112// `#[serde(default = "…")]` slots above route through the ONE
113// substrate owner [`crate::lifetime::default_ephemeral_ttl`] +
114// [`crate::lifetime::default_ephemeral_max_concurrent`] — peer of
115// the [`EphemeralLifetime`] serde-default slots on the SAME
116// workspace-canonical "ephemeral wire-form defaults" axis.
117// Pre-lift both slots carried their own private
118// `fn default_*` shims that returned bytewise-identical `"1h"` /
119// `1` values as the peer [`EphemeralLifetime`] slots — one of THREE
120// (TTL) and TWO (max-concurrent) restatements past the ★★ PRIME-
121// DIRECTIVE ≥ 2 duplication threshold. See the substrate owner's
122// doc-comment for the full migration rationale.
123
124impl EphemeralSpec {
125    /// True iff at least one [`Condition`] in
126    /// `preconditions ∪ postconditions` carries the given
127    /// [`ConditionKind`] — the peer of
128    /// [`crate::boundary::Boundary::has_condition_kind`] on the
129    /// [`EphemeralSpec`] surface.
130    ///
131    /// # Semantics — byte-identical to [`Boundary::has_condition_kind`]
132    ///
133    /// The two condition vectors are unioned: a caller asking "does this
134    /// ephemeral spec name a `ClosedLoopAuth` predicate anywhere" doesn't
135    /// care whether the operator authored it on the pre- or post-
136    /// condition side. A spec with the given kind on ONLY preconditions
137    /// returns `true`; a spec with the given kind on ONLY postconditions
138    /// returns `true`; a spec with neither returns `false`.
139    ///
140    /// Both halves compose through the SAME slice-level substrate
141    /// primitive [`ConditionSliceExt::has_kind`] that
142    /// [`Boundary::has_condition_kind`] walks — so a regression at the
143    /// per-slice presence probe fails at that primitive's tests rather
144    /// than as silent drift at either struct-level union caller.
145    ///
146    /// # Sibling to [`Boundary::has_condition_kind`]
147    ///
148    /// Same shape, same axis, same body — [`Boundary::has_condition_kind`]
149    /// composes `preconditions ∪ postconditions` on the point-domain
150    /// [`ProcessSpec`]'s nested [`Boundary`] slot;
151    /// [`Self::has_condition_kind`] composes the SAME union on
152    /// [`EphemeralSpec`]'s direct pre/post fields. `EphemeralSpec` has no
153    /// nested [`Boundary`] struct — the pre/post condition vectors are
154    /// stored directly on the sugar-surface type — so a byte-identical
155    /// inherent method here lets the ephemeral require-tag surface in
156    /// `tatara-reconciler::bin::tatara-check` publish a `condition-<kind>`
157    /// closed-set prefix family byte-for-byte symmetrical with the point
158    /// surface's family via [`Boundary::has_condition_kind`].
159    ///
160    /// # Compounding
161    ///
162    /// The ephemeral require-tag classifier composes this primitive with
163    /// the closed-set `FromStr` autoderived on [`ConditionKind`] through
164    /// the `strip_and_classify_prefixed_kind` substrate to publish a
165    /// fifth closed-set-driven prefix family across the workspace-wide
166    /// require-tag algebra (peer of `intent-<kind>` / `lifetime-<kind>` /
167    /// `condition-<kind>` / `must-reach-<kind>` on the point surface). A
168    /// future [`ConditionKind`] variant added to `ALL` reaches BOTH
169    /// surfaces' `condition-<kind>` prefix families through the SAME
170    /// closed-set walk with no per-caller edit — the two-surface
171    /// symmetry means adding a variant on the closed set publishes it in
172    /// lockstep across every downstream consumer.
173    ///
174    /// A future normalization at the presence-probe shape (a widened
175    /// return carrying the matching Condition ref, a debug-build
176    /// assertion on pre/post drift, a fleet-wide warn on redundant
177    /// duplicates) lands at the ONE slice-level substrate primitive
178    /// [`ConditionSliceExt::has_kind`] both this method and
179    /// [`Boundary::has_condition_kind`] compose against — so the two
180    /// struct-level union methods stay symmetric by construction.
181    ///
182    /// Theory anchor: THEORY.md §II.1 invariant 5 (composition preserves
183    /// proofs — the union body composes the SAME slice-level substrate
184    /// primitive on both this ephemeral surface and the point-domain
185    /// [`Boundary`] surface). THEORY.md §VI.1 (generation over
186    /// composition — a future [`ConditionKind`] variant added to `ALL`
187    /// reaches both `condition-<kind>` require-tag surfaces mechanically
188    /// through the SAME closed-set walk).
189    #[must_use]
190    pub fn has_condition_kind(&self, kind: ConditionKind) -> bool {
191        self.preconditions.has_kind(kind) || self.postconditions.has_kind(kind)
192    }
193}
194
195impl From<EphemeralSpec> for ProcessSpec {
196    fn from(e: EphemeralSpec) -> Self {
197        let classification = e.classification.unwrap_or_else(default_ephemeral_class);
198        let mut spec = Self {
199            identity: crate::spec::IdentitySpec {
200                parent: e.parent,
201                name_override: None,
202            },
203            classification,
204            intent: Intent {
205                aplicacao: Some(e.aplicacao),
206                ..Intent::default()
207            },
208            boundary: Boundary {
209                preconditions: e.preconditions,
210                postconditions: e.postconditions,
211                timeout: e.verify_timeout,
212            },
213            compliance: Default::default(),
214            depends_on: vec![],
215            signals: Default::default(),
216            // Routes through the ONE substrate composer
217            // [`Lifetime::ephemeral`] — pre-lift this was one of
218            // ELEVEN+ hand-authored `Lifetime { ephemeral: Some(<e>),
219            // .. }` sites past the ★★ PRIME-DIRECTIVE ≥ 2 threshold.
220            // See the composer's doc-comment for the full migration
221            // rationale.
222            lifetime: Lifetime::ephemeral(EphemeralLifetime {
223                ttl: e.ttl,
224                teardown_policy: e.teardown,
225                max_concurrent: e.max_concurrent,
226                exports: e.exports,
227            }),
228            // R5 — propagate routing template (None = no edges).
229            routing: e.routing,
230            // EncapsulatesSpec isn't exposed via EphemeralSpec sugar;
231            // operators wanting Adopt/Observe author the full
232            // (defpoint …) form. Sugar path stays greenfield-Manage.
233            encapsulates: None,
234            suspended: false,
235        };
236        // Belt-and-suspenders: make sure exactly-one Intent invariant holds.
237        spec.intent.nix = None;
238        spec.intent.flux = None;
239        spec.intent.lisp = None;
240        spec.intent.container = None;
241        spec.intent.guest = None;
242        spec
243    }
244}
245
246fn default_ephemeral_class() -> Classification {
247    // Delegates through the substrate `(Gate, Compute)` baseline owner
248    // so the shape lives at ONE workspace-wide site — see
249    // [`Classification::gate_compute`] for the pre-lift ten-callsite
250    // duplication history and the sibling-default correspondence
251    // pinned there.
252    Classification::gate_compute()
253}
254
255/// Compile a `(defephemeral …)` Lisp source into named `EphemeralSpec` values.
256pub fn compile_ephemeral_source(
257    src: &str,
258) -> tatara_lisp::Result<Vec<tatara_lisp::NamedDefinition<EphemeralSpec>>> {
259    tatara_lisp::compile_named::<EphemeralSpec>(src)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::boundary::ConditionKind;
266    use crate::classification::{ConvergencePointType, SubstrateType};
267    use crate::intent::IntentVariant;
268    use crate::lifetime::LifetimeVariant;
269
270    fn demo_overlay() -> AplicacaoIntent {
271        AplicacaoIntent {
272            chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
273            version: "0.5.5".into(),
274            profile: "all-in-one".into(),
275            values_overlay: serde_json::json!({
276                "cluster": { "name": "ephemeral-test-01", "namespace": "demo-test" },
277                "data": { "mysql": { "persistence": { "enabled": false } } },
278                "compliance": { "overlays": [] }
279            }),
280            release_name: Some("demo-app-consolidated".into()),
281            target_namespace: Some("demo-test".into()),
282            install_timeout: Some("25m".into()),
283        }
284    }
285
286    #[test]
287    fn defaults_resolve_for_ephemeral_spec() {
288        let e = EphemeralSpec {
289            aplicacao: demo_overlay(),
290            ttl: crate::lifetime::default_ephemeral_ttl(),
291            teardown: TeardownPolicy::default(),
292            max_concurrent: crate::lifetime::default_ephemeral_max_concurrent(),
293            postconditions: vec![],
294            preconditions: vec![],
295            verify_timeout: None,
296            classification: None,
297            parent: None,
298            exports: vec![],
299            routing: None,
300        };
301        let ps: ProcessSpec = e.into();
302        // Intent must resolve to Aplicacao.
303        match ps.intent.variant().unwrap() {
304            IntentVariant::Aplicacao(a) => {
305                assert_eq!(a.profile, "all-in-one");
306                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
307            }
308            other => panic!("expected Aplicacao, got {other:?}"),
309        }
310        // Lifetime must resolve to Ephemeral with defaults.
311        match ps.lifetime.variant().unwrap() {
312            LifetimeVariant::Ephemeral(e) => {
313                assert_eq!(e.ttl, "1h");
314                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
315            }
316            other => panic!("expected ephemeral, got {other:?}"),
317        }
318        // Default classification gates the Process at Compute/Internal.
319        assert_eq!(ps.classification.point_type, ConvergencePointType::Gate);
320        assert_eq!(ps.classification.substrate, SubstrateType::Compute);
321    }
322
323    #[test]
324    fn ephemeral_lisp_round_trip() {
325        let src = r#"
326            (defephemeral closed-loop-attest
327              :aplicacao (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
328                          :version "0.5.5"
329                          :profile "all-in-one"
330                          :values-overlay (:cluster (:name "ephemeral-test-01")
331                                           :data (:mysql (:persistence (:enabled #f)))
332                                           :compliance (:overlays []))
333                          :release-name "demo-app-consolidated"
334                          :target-namespace "demo-test"
335                          :install-timeout "25m")
336              :ttl "1h"
337              :teardown OnAttested
338              :max-concurrent 1
339              :postconditions
340                ((:kind HelmReleaseReleased
341                  :params (:name "demo-app-consolidated"
342                           :namespace "demo-test"))
343                 (:kind ClosedLoopAuth
344                  :params (:issuer (:service "demo-app-issuer" :port 8080)
345                           :consumer (:service "demo-app-gateway" :port 8000)
346                           :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
347        "#;
348        let defs = compile_ephemeral_source(src).expect("compile");
349        assert_eq!(defs.len(), 1);
350        let d = &defs[0];
351        assert_eq!(d.name, "closed-loop-attest");
352
353        // Aplicacao body landed correctly.
354        assert_eq!(
355            d.spec.aplicacao.chart_ref,
356            "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
357        );
358        assert_eq!(d.spec.aplicacao.profile, "all-in-one");
359        assert_eq!(
360            d.spec.aplicacao.target_namespace.as_deref(),
361            Some("demo-test")
362        );
363        // values-overlay JSON is preserved.
364        assert_eq!(
365            d.spec.aplicacao.values_overlay["cluster"]["name"],
366            "ephemeral-test-01"
367        );
368        // Boolean #f is preserved as a typed JSON bool (not the string "false").
369        // tatara-lisp uses Scheme syntax for bools — `#t` / `#f`.
370        assert_eq!(
371            d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
372            false
373        );
374
375        // Lifetime knobs.
376        assert_eq!(d.spec.ttl, "1h");
377        assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
378        assert_eq!(d.spec.max_concurrent, 1);
379
380        // Two postconditions, both typed.
381        assert_eq!(d.spec.postconditions.len(), 2);
382        assert_eq!(
383            d.spec.postconditions[0].kind,
384            ConditionKind::HelmReleaseReleased
385        );
386        assert_eq!(d.spec.postconditions[1].kind, ConditionKind::ClosedLoopAuth);
387
388        // Lowers to ProcessSpec with the right shape.
389        let ps: ProcessSpec = d.spec.clone().into();
390        assert!(matches!(
391            ps.intent.variant().unwrap(),
392            IntentVariant::Aplicacao(_)
393        ));
394        assert!(matches!(
395            ps.lifetime.variant().unwrap(),
396            LifetimeVariant::Ephemeral(_)
397        ));
398        assert_eq!(ps.boundary.postconditions.len(), 2);
399    }
400
401    /// End-to-end: the `:exports` slot on `(defephemeral …)` compiles
402    /// into typed `ExportSpec` values via the Universal-Deserialize
403    /// fallthrough — no per-domain keyword handlers needed.
404    ///
405    /// Receipts (empty-body source) is exercised via the Rust serde
406    /// path only (see `export::tests::export_spec_serde_round_trip`).
407    /// tatara-lisp's empty-kw-form `(:)` currently parses as a single-
408    /// element array rather than a JSON `{}`; the same limitation
409    /// affects `(:permanent)` on Lifetime. Tracked: extend the reader
410    /// to accept `(:foo (:))` ⇒ `{"foo": {}}` as a typed-empty form,
411    /// then re-enable Receipts here.
412    #[test]
413    fn exports_lisp_round_trip() {
414        use crate::export::{ArtifactVariant, ChannelVariant, ExportTrigger, ReportFormat};
415        let src = r#"
416            (defephemeral closed-loop-attest
417              :aplicacao (:chart-ref "oci://x"
418                          :version "1.0.0"
419                          :profile "minimal"
420                          :values-overlay ())
421              :ttl "30m"
422              :teardown OnAttested
423              :exports
424                ((:source  (:test-report (:configmap "junit-results"
425                                          :key       "junit.xml"
426                                          :format    Junit))
427                  :channel (:nats-subject (:subject "pleme.pleme-dev.ephemeral.r1.test-report"
428                                           :stream  "EPHEMERAL_TEST_REPORTS"))
429                  :when    OnAttested)
430                 (:source  (:test-report (:configmap "junit-results"
431                                          :key       "junit.xml"
432                                          :format    Junit))
433                  :channel (:http-event (:signal-type "test-report"))
434                  :when    Always)
435                 (:source  (:run-marker (:labels (:run-id "r1" :phase "end")))
436                  :channel (:http-event (:signal-type "ephemeral-marker"))
437                  :when    Always)))
438        "#;
439        let defs = compile_ephemeral_source(src).expect("compile");
440        assert_eq!(defs.len(), 1);
441        let d = &defs[0];
442        assert_eq!(d.spec.exports.len(), 3);
443
444        // First export — TestReport → NATS subject + OnAttested
445        let r = &d.spec.exports[0];
446        match r.source.variant().unwrap() {
447            ArtifactVariant::TestReport(tr) => {
448                assert_eq!(tr.configmap, "junit-results");
449                assert_eq!(tr.format, ReportFormat::Junit);
450            }
451            other => panic!("expected TestReport, got {other:?}"),
452        }
453        match r.channel.variant().unwrap() {
454            ChannelVariant::NatsSubject(n) => {
455                assert_eq!(n.subject, "pleme.pleme-dev.ephemeral.r1.test-report");
456                assert_eq!(n.stream, "EPHEMERAL_TEST_REPORTS");
457            }
458            other => panic!("expected NatsSubject, got {other:?}"),
459        }
460        assert_eq!(r.when, ExportTrigger::OnAttested);
461
462        // Second export — TestReport → HTTP + Always
463        let t = &d.spec.exports[1];
464        match t.channel.variant().unwrap() {
465            ChannelVariant::HttpEvent(h) => assert_eq!(h.signal_type, "test-report"),
466            other => panic!("expected HttpEvent, got {other:?}"),
467        }
468        assert_eq!(t.when, ExportTrigger::Always);
469
470        // Third export — RunMarker (BTreeMap<String,String> round-trip).
471        // tatara-lisp lowercases + normalizes keyword keys before
472        // handing off to serde_json — kebab `:run-id` may land as
473        // either `run-id` or `runId` depending on the reader path.
474        // Accept either; the round-trip property under test is
475        // "label survives compile" not "exact case-form".
476        let m = &d.spec.exports[2];
477        match m.source.variant().unwrap() {
478            ArtifactVariant::RunMarker(rm) => {
479                assert_eq!(rm.labels.len(), 2);
480                let run_id = rm
481                    .labels
482                    .get("run-id")
483                    .or_else(|| rm.labels.get("runId"))
484                    .or_else(|| rm.labels.get("run_id"))
485                    .expect("run-id label present under some normalization");
486                assert_eq!(run_id, "r1");
487                assert_eq!(rm.labels.get("phase").map(String::as_str), Some("end"));
488            }
489            other => panic!("expected RunMarker, got {other:?}"),
490        }
491
492        // Lowered ProcessSpec carries the exports through unchanged.
493        let ps: ProcessSpec = d.spec.clone().into();
494        assert_eq!(ps.lifetime.ephemeral.as_ref().unwrap().exports.len(), 3);
495    }
496
497    // ── EphemeralSpec::has_condition_kind substrate pins ─────────────
498    //
499    // Fail-before-pass-after granularity:
500    // `EphemeralSpec::has_condition_kind` did not exist before this
501    // commit — the (preconditions ∪ postconditions .iter().any(|c|
502    // c.kind == K)) union-probe shape lived at ONE struct-level site
503    // (`Boundary::has_condition_kind` on the point surface's nested
504    // [`Boundary`] slot). The lift adds the peer inherent method on the
505    // [`EphemeralSpec`] sugar-surface so both struct-level union
506    // callers compose against the SAME slice-level substrate primitive
507    // [`ConditionSliceExt::has_kind`] in lockstep. A regression that
508    // (a) hard-coded the arm to a single kind, (b) dropped the pre-
509    // condition side of the OR (a re-inheritance of the pre-lift
510    // ephemeral `closed-loop-auth` post-only shape at the union-tag
511    // level), or (c) probed the wrong slot fails HERE at the substrate
512    // primitive rather than as silent operator-facing drift at the
513    // ephemeral `condition-<kind>` require-tag surface.
514
515    fn empty_ephemeral() -> EphemeralSpec {
516        EphemeralSpec {
517            aplicacao: AplicacaoIntent::chart_only("oci://ghcr.io/x", "1"),
518            ttl: "1h".into(),
519            teardown: TeardownPolicy::Always,
520            max_concurrent: 0,
521            postconditions: vec![],
522            preconditions: vec![],
523            verify_timeout: None,
524            classification: None,
525            parent: None,
526            exports: vec![],
527            routing: None,
528        }
529    }
530
531    fn cond(kind: ConditionKind) -> Condition {
532        Condition {
533            kind,
534            params: serde_json::json!({}),
535        }
536    }
537
538    /// EMPTY-SPEC pin — a default [`EphemeralSpec`] (empty
539    /// preconditions, empty postconditions) returns `false` for EVERY
540    /// [`ConditionKind`]. Sweep `ConditionKind::ALL` so a new variant
541    /// added without a matching arm in the presence probe surfaces at
542    /// rustc's exhaustiveness gate on the ALL literal (arity forced by
543    /// `[Self; 8]`) rather than as a silent false-positive at every
544    /// downstream `condition-<kind>` ephemeral require-tag callsite.
545    /// Byte-for-byte peer of
546    /// `has_condition_kind_returns_false_on_empty_boundary_for_every_kind`
547    /// on the [`Boundary`] surface.
548    #[test]
549    fn has_condition_kind_returns_false_on_empty_ephemeral_for_every_kind() {
550        let spec = empty_ephemeral();
551        for kind in ConditionKind::ALL {
552            assert!(
553                !spec.has_condition_kind(kind),
554                "empty ephemeral spec must return false for {kind:?}",
555            );
556        }
557    }
558
559    /// POSTCONDITION-only pin — an ephemeral spec that carries the
560    /// kind on ONLY postconditions returns `true` for that kind,
561    /// `false` for every other variant. Sweep the ALL × ALL cross so
562    /// a regression that hard-coded the arm to a single kind or
563    /// probed the wrong slot fails HERE at the substrate primitive.
564    #[test]
565    fn has_condition_kind_reads_ephemeral_postconditions_per_kind() {
566        for populated in ConditionKind::ALL {
567            let mut spec = empty_ephemeral();
568            spec.postconditions.push(cond(populated));
569            for query in ConditionKind::ALL {
570                let expected = query == populated;
571                assert_eq!(
572                    spec.has_condition_kind(query),
573                    expected,
574                    "ephemeral postcondition populated={populated:?}: \
575                     query {query:?} drifted",
576                );
577            }
578        }
579    }
580
581    /// PRECONDITION-only pin — mirrors the postcondition sweep on the
582    /// other half of the union. Locks the union semantics on both
583    /// halves separately so a regression that dropped the pre-
584    /// condition side of the OR fails here even though the
585    /// postcondition-side pin above passes.
586    #[test]
587    fn has_condition_kind_reads_ephemeral_preconditions_per_kind() {
588        for populated in ConditionKind::ALL {
589            let mut spec = empty_ephemeral();
590            spec.preconditions.push(cond(populated));
591            for query in ConditionKind::ALL {
592                let expected = query == populated;
593                assert_eq!(
594                    spec.has_condition_kind(query),
595                    expected,
596                    "ephemeral precondition populated={populated:?}: \
597                     query {query:?} drifted",
598                );
599            }
600        }
601    }
602
603    /// UNION pin — a kind that appears on preconditions returns
604    /// `true` even when postconditions carries a DIFFERENT kind, and
605    /// vice versa. Pins the OR-composition of the two halves so a
606    /// regression that collapsed the union to an intersection (AND)
607    /// silently reclassifies pre-only or post-only kinds as absent.
608    /// Byte-for-byte peer of
609    /// `has_condition_kind_unions_pre_and_post_condition_arms` on the
610    /// [`Boundary`] surface.
611    #[test]
612    fn has_condition_kind_unions_pre_and_post_ephemeral_condition_arms() {
613        let mut spec = empty_ephemeral();
614        spec.preconditions
615            .push(cond(ConditionKind::KustomizationHealthy));
616        spec.postconditions
617            .push(cond(ConditionKind::ClosedLoopAuth));
618        assert!(
619            spec.has_condition_kind(ConditionKind::KustomizationHealthy),
620            "pre-only kind must resolve through the union",
621        );
622        assert!(
623            spec.has_condition_kind(ConditionKind::ClosedLoopAuth),
624            "post-only kind must resolve through the union",
625        );
626        assert!(
627            !spec.has_condition_kind(ConditionKind::PromQL),
628            "an absent kind must return false even with populated halves",
629        );
630    }
631
632    /// COMPOSITION pin — [`EphemeralSpec::has_condition_kind`] equals
633    /// the OR of the two slice-level probes on the pre/post fields.
634    /// The struct-level union body composes ONLY [`ConditionSliceExt::has_kind`]
635    /// on each half; a regression that inlined a wide-net predicate
636    /// (`.iter().any(|c| c.kind != kind).not()`, an `all` instead of
637    /// `any`) drifts from the slice-level primitive here. Byte-for-
638    /// byte peer of the
639    /// `boundary_has_condition_kind_equals_or_of_half_slice_probes`
640    /// composition pin on the [`Boundary`] surface.
641    #[test]
642    fn ephemeral_has_condition_kind_equals_or_of_half_slice_probes() {
643        // Sweep every ConditionKind on both halves independently so the
644        // cross of half-slice probes reaches the OR-composition body
645        // exhaustively.
646        for populated in ConditionKind::ALL {
647            let mut spec = empty_ephemeral();
648            spec.preconditions.push(cond(populated));
649            spec.postconditions.push(cond(ConditionKind::PromQL));
650            for query in ConditionKind::ALL {
651                let via_or_of_halves =
652                    spec.preconditions.has_kind(query) || spec.postconditions.has_kind(query);
653                assert_eq!(
654                    spec.has_condition_kind(query),
655                    via_or_of_halves,
656                    "populated={populated:?} query={query:?}: struct-level \
657                     union drifted from OR of slice-level probes",
658                );
659            }
660        }
661    }
662
663    #[test]
664    fn from_impl_clears_other_intent_variants() {
665        // Even if someone constructs an EphemeralSpec by hand and the
666        // resulting ProcessSpec is later mutated, the From bridge sets
667        // every non-Aplicacao slot to None explicitly.
668        let e = EphemeralSpec {
669            aplicacao: demo_overlay(),
670            ttl: "10m".into(),
671            teardown: TeardownPolicy::Never,
672            max_concurrent: 0,
673            postconditions: vec![],
674            preconditions: vec![],
675            verify_timeout: None,
676            classification: None,
677            parent: Some("seph.1".into()),
678            exports: vec![],
679            routing: None,
680        };
681        let ps: ProcessSpec = e.into();
682        assert!(ps.intent.nix.is_none());
683        assert!(ps.intent.flux.is_none());
684        assert!(ps.intent.lisp.is_none());
685        assert!(ps.intent.container.is_none());
686        assert!(ps.intent.guest.is_none());
687        assert!(ps.intent.aplicacao.is_some());
688        assert_eq!(ps.identity.parent.as_deref(), Some("seph.1"));
689    }
690}