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}