Skip to main content

tatara_process/
encapsulates.rs

1//! `EncapsulatesSpec` — how a Process relates to pre-existing
2//! in-cluster state.
3//!
4//! The substrate move: every long-running workload on a pleme-io
5//! cluster — raw HelmReleases, Flux Kustomizations, bare Deployments
6//! — becomes a Process without disruption. Three modes:
7//!
8//! * **Manage** (default) — Process IS the control loop. New
9//!   HR/Kustomization emitted by the reconciler use ownerRefs
10//!   pointing at the Process; cascade-delete on Reaped.
11//!
12//! * **Adopt** — Take over an existing HR/Kustomization in place.
13//!   Reconciler emits a new HR with `releaseName` matching the
14//!   running release; helm-controller adopts the existing release
15//!   under new management. **No pod restart**; no values diff
16//!   unless operator changes them. The original raw HR can be
17//!   deleted from git after the takeover confirms.
18//!
19//! * **Observe** — Read-only awareness. Process watches the existing
20//!   state for postcondition pillars + emits routing/exports/
21//!   attestation, but does NOT modify or own the underlying
22//!   HR/Kustomization. Useful for adding DNS + observability to
23//!   legacy stacks without taking over.
24//!
25//! These compose progressively: Observe an HR first to confirm
26//! shape, promote to Adopt for zero-downtime takeover, then to
27//! Manage once the Process drives values.
28//!
29//! Lisp authoring:
30//! ```lisp
31//! :encapsulates (:kind (:existing-helm-release
32//!                       :namespace    "akeyless"
33//!                       :name         "akeyless-saas"
34//!                       :release-name "akeyless-saas-consolidated")
35//!                :mode Adopt)
36//! ```
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41use tatara_lisp::DeriveTataraDomain;
42
43/// How a Process wraps pre-existing in-cluster state.
44///
45/// Optional on `ProcessSpec` — None means the Process is greenfield
46/// (Manage mode applied to nothing pre-existing). The render phase
47/// branches on `kind` to decide whether to emit fresh resources or
48/// reference/adopt running ones.
49#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "camelCase")]
51#[tatara(keyword = "defencapsulates")]
52pub struct EncapsulatesSpec {
53    /// What kind of pre-existing state.
54    pub kind: EncapsulationKind,
55
56    /// Reconciler's relationship to that state. Defaults to `Manage`
57    /// (the operational default when `encapsulates` is set without
58    /// an explicit mode).
59    #[serde(default)]
60    pub mode: EncapsulationMode,
61}
62
63/// Three concrete kinds the substrate knows how to wrap. Exactly-
64/// one-Option pattern matching `Intent` / `Lifetime` — additive on
65/// the wire, every variant typed.
66#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct EncapsulationKind {
69    /// An existing FluxCD HelmRelease. The reconciler emits a new HR
70    /// with the SAME `release_name` — helm-controller finds + adopts
71    /// the in-cluster release without recreating Pods.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub existing_helm_release: Option<ExistingHelmRelease>,
74
75    /// An existing FluxCD Kustomization. The reconciler stops emitting
76    /// its own and instead references the existing one.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub existing_kustomization: Option<ExistingKustomization>,
79
80    /// Pre-existing in-cluster workload (Deployment/StatefulSet/etc)
81    /// not Flux-managed. The reconciler adds ownerRefs + emits
82    /// routing only. The workload stays where it is.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub bare_workload: Option<BareWorkload>,
85}
86
87/// Resolved enum view used by the render phase.
88#[derive(Clone, Debug)]
89pub enum EncapsulationKindVariant<'a> {
90    ExistingHelmRelease(&'a ExistingHelmRelease),
91    ExistingKustomization(&'a ExistingKustomization),
92    BareWorkload(&'a BareWorkload),
93}
94
95impl EncapsulationKindVariant<'_> {
96    /// Reverse projection — every borrowed variant knows its
97    /// [`EncapsulationTarget`] discriminator. Pairs with
98    /// [`EncapsulationTarget::select`] so
99    /// `EncapsulationTarget::select(kind).map(|v| v.target())`
100    /// round-trips the closed set on the populated side; pinned by
101    /// `encapsulation_target_round_trips_through_variant_target`.
102    /// Future target-keyed consumers (metric labels like
103    /// `tatara_encapsulations_total{target="existingHelmRelease"}`,
104    /// status reason strings, audit-trail classifiers, LSP completion
105    /// lists) reach through this projection instead of pattern-matching
106    /// the payload-carrying view.
107    pub fn target(&self) -> EncapsulationTarget {
108        match self {
109            Self::ExistingHelmRelease(_) => EncapsulationTarget::ExistingHelmRelease,
110            Self::ExistingKustomization(_) => EncapsulationTarget::ExistingKustomization,
111            Self::BareWorkload(_) => EncapsulationTarget::BareWorkload,
112        }
113    }
114}
115
116/// Closed-set discriminator over `EncapsulationKind`'s three tagged-union
117/// slots. Single source of truth that drives `EncapsulationKind::variant`'s
118/// ambiguity + emptiness resolver, the `EncapsulationKindError::Empty`
119/// diagnostic message, and the reverse `EncapsulationKindVariant::target`
120/// projection. Adding a fourth encapsulation target (e.g., a future
121/// `ExistingNamespace`, `ExistingDaemonSet`, or `ExistingService`) lands
122/// at one `ALL` entry + one `as_str` arm + one `select` arm + one
123/// `EncapsulationKindVariant::target` arm — exhaustively checked by the
124/// compiler.
125///
126/// The (open authoring surface, closed typed discriminator) split mirrors
127/// every other multi-Option tagged union on this `ProcessSpec` axis:
128/// [`crate::intent::IntentKind`] discriminates [`crate::intent::Intent`];
129/// [`crate::lifetime::LifetimeKind`] discriminates
130/// [`crate::lifetime::Lifetime`];
131/// [`crate::export::ArtifactKind`] discriminates
132/// [`crate::export::ArtifactSource`];
133/// [`crate::export::ChannelKind`] discriminates
134/// [`crate::export::VectorChannel`]. The carrier here is named
135/// `EncapsulationKind` (not `Encapsulation`) because it predates the
136/// closed-set lift convention; `EncapsulationTarget` is the typed
137/// discriminator the rest of the typescape projects through, named for
138/// the semantic role each variant plays (a target of encapsulation).
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
140#[closed_set(via = "as_str", generate_unknown, display)]
141pub enum EncapsulationTarget {
142    ExistingHelmRelease,
143    ExistingKustomization,
144    BareWorkload,
145}
146
147impl EncapsulationTarget {
148    /// The closed set of encapsulation targets — single source of truth
149    /// that drives `EncapsulationKind::variant`'s sweep so a variant
150    /// added without an `ALL` entry never reaches the resolver. The
151    /// `[Self; 3]` array literal forces the arity at compile time.
152    pub const ALL: [Self; 3] = [
153        Self::ExistingHelmRelease,
154        Self::ExistingKustomization,
155        Self::BareWorkload,
156    ];
157
158    /// Canonical camelCase wire-format key — matches the serde
159    /// `rename_all = "camelCase"` field name on the corresponding
160    /// `Option<…>` slot of `EncapsulationKind`. The
161    /// `EncapsulationKindError::Empty` diagnostic composes the
162    /// human-readable list from this projection so a new variant lands
163    /// in the operator-facing diagnostic automatically via the `ALL`
164    /// sweep, not via hand-maintained error-string drift. Pinned by
165    /// `encapsulation_target_as_str_matches_field_name`.
166    pub const fn as_str(self) -> &'static str {
167        match self {
168            Self::ExistingHelmRelease => "existingHelmRelease",
169            Self::ExistingKustomization => "existingKustomization",
170            Self::BareWorkload => "bareWorkload",
171        }
172    }
173
174    /// Project an `EncapsulationKind` borrow into the optional typed
175    /// variant view for this target. Returns `None` iff the matching
176    /// slot is `None`. Composes the closed-set sweep
177    /// `EncapsulationKind::variant` loops over. Mirrors
178    /// [`crate::intent::IntentKind::select`],
179    /// [`crate::lifetime::LifetimeKind::select`],
180    /// [`crate::export::ArtifactKind::select`], and
181    /// [`crate::export::ChannelKind::select`].
182    pub fn select<'a>(self, kind: &'a EncapsulationKind) -> Option<EncapsulationKindVariant<'a>> {
183        match self {
184            Self::ExistingHelmRelease => kind
185                .existing_helm_release
186                .as_ref()
187                .map(EncapsulationKindVariant::ExistingHelmRelease),
188            Self::ExistingKustomization => kind
189                .existing_kustomization
190                .as_ref()
191                .map(EncapsulationKindVariant::ExistingKustomization),
192            Self::BareWorkload => kind
193                .bare_workload
194                .as_ref()
195                .map(EncapsulationKindVariant::BareWorkload),
196        }
197    }
198}
199
200// `impl FromStr for EncapsulationTarget` + `impl tatara_lisp::ClosedSet for
201// EncapsulationTarget` + `impl std::fmt::Display for EncapsulationTarget` are
202// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
203// declaration above. `label` delegates to the inherent
204// `EncapsulationTarget::as_str` via `#[closed_set(via = "as_str")]` so the
205// camelCase wire-format projection stays load-bearing (matches the serde
206// `rename_all = "camelCase"` field names on `EncapsulationKind` AND the
207// `ENCAPSULATION_TARGET_LIST` slash-joined operator diagnostic verbatim)
208// while generic `T: ClosedSet` consumers reach the STABLE workspace-wide
209// name (`label`). The `display` flag emits the `f.write_str(self.as_str())`
210// delegation block at the same proc-macro site rather than a hand-rolled
211// `fmt::Display` block per implementor.
212
213// `pub struct UnknownEncapsulationTarget(pub String)` is generated by
214// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
215// on the enum declaration above. The auto-derived label `"encapsulation target"`
216// matches the prior hand-rolled `#[error("unknown encapsulation target: {0}")]`
217// verbatim — pinned generically by clause (5) of
218// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>()` (called
219// from `encapsulation_target_is_well_formed_closed_set` in the test module).
220// Symmetric to [`UnknownEncapsulationMode`], [`crate::export::UnknownArtifactKind`],
221// [`crate::export::UnknownChannelKind`], and
222// [`crate::lifetime::UnknownTeardownPolicy`].
223
224#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
225pub enum EncapsulationKindError {
226    #[error("encapsulation kind has no variant set (one of {0} required)")]
227    Empty(&'static str),
228    #[error("encapsulation kind has multiple variants set; exactly one required")]
229    Ambiguous,
230}
231
232/// Slash-joined list of every `EncapsulationTarget::as_str()` — composed
233/// once at compile time so `EncapsulationKindError::Empty`'s diagnostic
234/// carries the closed-set summary without per-variant string drift.
235/// Mirrors [`crate::intent::INTENT_KIND_LIST`] /
236/// [`crate::export::ARTIFACT_KIND_LIST`] in shape; pinned by
237/// `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`.
238const ENCAPSULATION_TARGET_LIST: &str = "existingHelmRelease/existingKustomization/bareWorkload";
239
240impl EncapsulationKind {
241    /// Resolve to exactly one variant. Errors on zero or many.
242    ///
243    /// Sweeps over [`EncapsulationTarget::ALL`] so a fourth variant added
244    /// with an `ALL` entry is structurally honored at this site — no
245    /// parallel `is_some()` count, no per-variant if-let chain, no
246    /// `unreachable!()`. The Empty diagnostic carries the closed-set
247    /// list via `ENCAPSULATION_TARGET_LIST`.
248    pub fn variant(&self) -> Result<EncapsulationKindVariant<'_>, EncapsulationKindError> {
249        use crate::tagged_union::{resolve, ResolveError};
250        resolve(EncapsulationTarget::ALL.into_iter().map(|t| t.select(self))).map_err(|e| match e {
251            ResolveError::None => EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST),
252            ResolveError::Many => EncapsulationKindError::Ambiguous,
253        })
254    }
255}
256
257/// Pointer to an existing FluxCD HelmRelease the Process wraps.
258#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct ExistingHelmRelease {
261    /// Namespace of the HelmRelease CR.
262    pub namespace: String,
263    /// Name of the HelmRelease CR.
264    pub name: String,
265    /// The `spec.releaseName` Helm used for the actual chart install.
266    /// For Adopt mode, the reconciler's emitted HR matches this so
267    /// helm-controller adopts in-place. Required because the HR's
268    /// `metadata.name` and `spec.releaseName` aren't always equal.
269    pub release_name: String,
270}
271
272/// Pointer to an existing FluxCD Kustomization the Process wraps.
273#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
274#[serde(rename_all = "camelCase")]
275pub struct ExistingKustomization {
276    pub namespace: String,
277    pub name: String,
278}
279
280/// Pointer to a bare in-cluster workload (not Flux-managed). The
281/// reconciler identifies the underlying Pods by `selector` and adds
282/// ownerRefs / routing without emitting a new HR/Kustomization.
283#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
284#[serde(rename_all = "camelCase")]
285pub struct BareWorkload {
286    /// Namespace the workload lives in.
287    pub namespace: String,
288    /// Label selector. Must match a single Deployment/StatefulSet/
289    /// DaemonSet; multiple matches are a config error.
290    pub selector: BTreeMap<String, String>,
291}
292
293/// Three modes the reconciler dispatches on at render time.
294#[derive(
295    Clone,
296    Copy,
297    Debug,
298    Default,
299    Serialize,
300    Deserialize,
301    JsonSchema,
302    PartialEq,
303    Eq,
304    Hash,
305    tatara_closed_set::DeriveClosedSet,
306)]
307#[serde(rename_all = "PascalCase")]
308#[closed_set(via = "as_str", generate_unknown, display)]
309pub enum EncapsulationMode {
310    /// **Default** — Process IS the control loop for whatever is
311    /// inside. Emitted HR/Kustomization carry the Process's
312    /// ownerRefs; cascade-delete on Reaped.
313    #[default]
314    Manage,
315
316    /// **Adopt** — Take over the existing release/kustomization in
317    /// place. New HR emitted matches the existing `releaseName`;
318    /// pods don't restart. Used during migration from raw HR → Process.
319    Adopt,
320
321    /// **Observe** — Read-only. Emit routing/exports/attestation but
322    /// don't modify the underlying HR/Kustomization. Used to add
323    /// DNS + observability to legacy stacks without taking over.
324    Observe,
325}
326
327impl EncapsulationMode {
328    /// The closed set of encapsulation modes — single source of truth
329    /// that drives the `as_str` / Display / `FromStr` triad and the
330    /// typed `emits_workload` / `preserves_release_name` dispatch.
331    /// Adding a fourth variant lands at one `ALL` entry + one `as_str`
332    /// arm + one arm in each of the two boolean projections —
333    /// exhaustively checked by the compiler (the `[Self; 3]` array
334    /// literal forces the arity).
335    ///
336    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
337    /// [`crate::export::ExportTrigger::ALL`],
338    /// [`crate::lifetime::TeardownPolicy::ALL`],
339    /// [`crate::intent::IntentKind::ALL`],
340    /// [`crate::lifetime::LifetimeKind::ALL`],
341    /// [`crate::boundary::ConditionKind::ALL`],
342    /// [`crate::phase::ProcessPhase::ALL`],
343    /// [`crate::signal::ProcessSignal::ALL`].
344    pub const ALL: [Self; 3] = [Self::Manage, Self::Adopt, Self::Observe];
345
346    /// Canonical PascalCase wire-format projection — matches the serde
347    /// `rename_all = "PascalCase"` output verbatim. Used by Display
348    /// (single source of truth), by `FromStr` to identify the variant
349    /// from its annotation / status-field representation, and by
350    /// operator-facing reason strings without reaching for `{:?}` Debug
351    /// formatting. Pinned by `mode_as_str_matches_serde`.
352    pub const fn as_str(self) -> &'static str {
353        match self {
354            Self::Manage => "Manage",
355            Self::Adopt => "Adopt",
356            Self::Observe => "Observe",
357        }
358    }
359
360    /// True iff the reconciler should emit (or re-emit) the
361    /// underlying HR/Kustomization at render time.
362    /// Observe ⇒ false; Manage/Adopt ⇒ true.
363    ///
364    /// Closed-set match (not `matches!`) so adding a fourth variant
365    /// triggers the compiler's exhaustiveness check at this site
366    /// rather than silently defaulting to `false`. ONE typed dispatch
367    /// over the closed set that replaces the
368    /// `mode == EncapsulationMode::Observe` hand-rolled equality at
369    /// the reconciler's render entry — the truth table for "should
370    /// this mode emit a workload?" is now owned by the typed surface,
371    /// not by a pattern fragment two crates have to keep coherent.
372    pub const fn emits_workload(self) -> bool {
373        match self {
374            Self::Manage | Self::Adopt => true,
375            Self::Observe => false,
376        }
377    }
378
379    /// True iff the reconciler should preserve the existing release
380    /// name (so helm-controller adopts in-place). Only Adopt.
381    ///
382    /// Closed-set match (not `matches!`) so adding a fourth variant
383    /// triggers the compiler's exhaustiveness check at this site.
384    /// ONE typed dispatch that replaces the
385    /// `mode == EncapsulationMode::Adopt` hand-rolled equality at the
386    /// reconciler's `render_aplicacao` adoption-annotation branch.
387    pub const fn preserves_release_name(self) -> bool {
388        match self {
389            Self::Adopt => true,
390            Self::Manage | Self::Observe => false,
391        }
392    }
393}
394
395// `impl FromStr for EncapsulationMode` + `impl tatara_lisp::ClosedSet for
396// EncapsulationMode` + `impl std::fmt::Display for EncapsulationMode` are
397// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
398// declaration above. `label` delegates to the inherent
399// `EncapsulationMode::as_str` via `#[closed_set(via = "as_str")]` so the
400// PascalCase wire-format projection stays load-bearing (matches the serde
401// `rename_all = "PascalCase"` external-tag form on the wire AND the
402// reconciler's `mode: {Manage,Adopt,Observe}` status-condition reason
403// strings verbatim) while generic `T: ClosedSet` consumers reach the
404// STABLE workspace-wide name (`label`). The `display` flag emits the
405// `f.write_str(self.as_str())` delegation block at the same proc-macro
406// site rather than a hand-rolled `fmt::Display` block per implementor.
407
408// `pub struct UnknownEncapsulationMode(pub String)` is generated by
409// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
410// on the enum declaration above. The auto-derived label `"encapsulation mode"`
411// matches the prior hand-rolled `#[error("unknown encapsulation mode: {0}")]`
412// verbatim — pinned generically by clause (5) of
413// `tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>()` (called
414// from `mode_is_well_formed_closed_set` in the test module).
415// Symmetric to [`UnknownEncapsulationTarget`], [`crate::export::UnknownExportTrigger`],
416// [`crate::lifetime::UnknownTeardownPolicy`],
417// [`crate::boundary::UnknownConditionKind`], and
418// [`crate::phase::UnknownPhase`].
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn akeyless_adopt() -> EncapsulatesSpec {
425        EncapsulatesSpec {
426            kind: EncapsulationKind {
427                existing_helm_release: Some(ExistingHelmRelease {
428                    namespace: "akeyless".into(),
429                    name: "akeyless-saas".into(),
430                    release_name: "akeyless-saas-consolidated".into(),
431                }),
432                ..EncapsulationKind::default()
433            },
434            mode: EncapsulationMode::Adopt,
435        }
436    }
437
438    #[test]
439    fn kind_empty_errors() {
440        let k = EncapsulationKind::default();
441        assert_eq!(
442            k.variant().unwrap_err(),
443            EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
444        );
445    }
446
447    #[test]
448    fn kind_existing_hr_resolves() {
449        let s = akeyless_adopt();
450        match s.kind.variant().unwrap() {
451            EncapsulationKindVariant::ExistingHelmRelease(h) => {
452                assert_eq!(h.namespace, "akeyless");
453                assert_eq!(h.release_name, "akeyless-saas-consolidated");
454            }
455            other => panic!("expected ExistingHelmRelease, got {other:?}"),
456        }
457    }
458
459    #[test]
460    fn kind_two_variants_ambiguous() {
461        let k = EncapsulationKind {
462            existing_helm_release: Some(ExistingHelmRelease {
463                namespace: "ns".into(),
464                name: "n".into(),
465                release_name: "r".into(),
466            }),
467            existing_kustomization: Some(ExistingKustomization {
468                namespace: "ns".into(),
469                name: "n".into(),
470            }),
471            ..EncapsulationKind::default()
472        };
473        assert_eq!(k.variant().unwrap_err(), EncapsulationKindError::Ambiguous);
474    }
475
476    #[test]
477    fn mode_dispatch() {
478        assert!(EncapsulationMode::Manage.emits_workload());
479        assert!(EncapsulationMode::Adopt.emits_workload());
480        assert!(!EncapsulationMode::Observe.emits_workload());
481
482        assert!(!EncapsulationMode::Manage.preserves_release_name());
483        assert!(EncapsulationMode::Adopt.preserves_release_name());
484        assert!(!EncapsulationMode::Observe.preserves_release_name());
485    }
486
487    #[test]
488    fn mode_default_is_manage() {
489        assert_eq!(EncapsulationMode::default(), EncapsulationMode::Manage);
490    }
491
492    // ── closed-set algebra for EncapsulationMode (ALL × as_str ×
493    //    Display × FromStr × emits_workload × preserves_release_name) ─
494
495    /// Structural well-formedness of [`EncapsulationMode`] as a
496    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
497    /// testkit lift that pins all three structural invariants (`ALL`
498    /// is non-empty, every variant round-trips through
499    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
500    /// outside the closed set) at ONE call site. Replaces the hand-
501    /// derived `mode_all_is_unique_and_complete` +
502    /// `mode_roundtrip_via_as_str` + the empty-input arm of
503    /// `unknown_encapsulation_mode_errors`. `FromStr` delegates to
504    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
505    /// exercises the same code path the reconciler hits when parsing a
506    /// CRD `enum:`-validated `mode` value back to the typed mode.
507    #[test]
508    fn mode_is_well_formed_closed_set() {
509        tatara_closed_set::assert_closed_set_well_formed::<EncapsulationMode>();
510    }
511
512    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
513    /// output verbatim for every variant. A future variant rename
514    /// (or an `as_str` arm typo) lands here at one site, instead of
515    /// drifting between the typed surface and the YAML wire format
516    /// the reconciler / operator both read.
517    #[test]
518    fn mode_as_str_matches_serde() {
519        for mode in EncapsulationMode::ALL {
520            let serialized = serde_json::to_string(&mode).expect("serialize");
521            let unquoted = serialized
522                .trim_start_matches('"')
523                .trim_end_matches('"')
524                .to_string();
525            assert_eq!(
526                unquoted,
527                mode.as_str(),
528                "as_str drift for {mode:?}: as_str={} serde={unquoted}",
529                mode.as_str()
530            );
531        }
532    }
533
534    /// The Display impl IS `as_str` — pinning this lets future callers
535    /// reach for either projection without drift. If a reviewer
536    /// accidentally re-introduces an inline match in Display, this
537    /// test would fail the moment a variant rename touches one site
538    /// but not the other.
539    #[test]
540    fn mode_display_matches_as_str() {
541        for mode in EncapsulationMode::ALL {
542            assert_eq!(mode.to_string(), mode.as_str());
543        }
544    }
545
546    /// `FromStr` rejects strings that aren't in the canonical
547    /// projection — lowercased / typo / unrelated — and the error
548    /// echoes the input verbatim so the operator-facing diagnostic
549    /// carries the offending value, not a normalized form. The
550    /// empty-input arm is pinned by
551    /// [`mode_is_well_formed_closed_set`] via the
552    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
553    /// verbatim-echo contract on the [`UnknownEncapsulationMode`]
554    /// newtype, which the trait's `make_unknown` can't see.
555    #[test]
556    fn unknown_encapsulation_mode_errors() {
557        use std::str::FromStr;
558        for bad in ["manage", "ADOPT", "Observed", "Wrap"] {
559            let err = EncapsulationMode::from_str(bad).unwrap_err();
560            assert_eq!(err.0, bad, "error payload should echo input verbatim");
561        }
562    }
563
564    /// TRUTH-TABLE CONTRACT: `emits_workload` / `preserves_release_name`
565    /// agree with the documented (mode) -> (bool, bool) table for every
566    /// variant. A new variant in `EncapsulationMode` without extending
567    /// either projection's match is caught by the compiler (closed-set
568    /// match in each method); adding a variant without extending its
569    /// truth row is caught here.
570    #[test]
571    fn mode_projection_truth_table() {
572        let table: &[(EncapsulationMode, bool, bool)] = &[
573            // (mode, emits_workload, preserves_release_name)
574            (EncapsulationMode::Manage, true, false),
575            (EncapsulationMode::Adopt, true, true),
576            (EncapsulationMode::Observe, false, false),
577        ];
578        assert_eq!(table.len(), EncapsulationMode::ALL.len());
579        for (mode, emits, preserves) in table {
580            assert_eq!(
581                mode.emits_workload(),
582                *emits,
583                "emits_workload drift for {mode:?}"
584            );
585            assert_eq!(
586                mode.preserves_release_name(),
587                *preserves,
588                "preserves_release_name drift for {mode:?}"
589            );
590        }
591    }
592
593    /// DRIFT-PROOF CONTRACT: the hand-rolled
594    /// `mode == EncapsulationMode::Observe` and
595    /// `mode == EncapsulationMode::Adopt` checks the reconciler's
596    /// `render` function used pre-lift agree with the typed
597    /// projections for every variant in `ALL`. A regression that
598    /// re-introduces a raw `==` against a variant name fails here:
599    /// `!emits_workload()` IS "Observe mode" and
600    /// `preserves_release_name()` IS "Adopt mode", expressed as a
601    /// property of the typed surface rather than a pattern fragment
602    /// two crates have to keep coherent.
603    #[test]
604    fn mode_typed_projections_replace_raw_equality() {
605        for mode in EncapsulationMode::ALL {
606            assert_eq!(
607                !mode.emits_workload(),
608                mode == EncapsulationMode::Observe,
609                "!emits_workload() drift for {mode:?}"
610            );
611            assert_eq!(
612                mode.preserves_release_name(),
613                mode == EncapsulationMode::Adopt,
614                "preserves_release_name() drift for {mode:?}"
615            );
616        }
617    }
618
619    #[test]
620    fn serde_round_trip_via_yaml() {
621        let s = akeyless_adopt();
622        let yaml = serde_yaml::to_string(&s).unwrap();
623        assert!(yaml.contains("existingHelmRelease:"));
624        assert!(yaml.contains("releaseName: akeyless-saas-consolidated"));
625        assert!(yaml.contains("mode: Adopt"));
626        let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
627        assert!(back.kind.existing_helm_release.is_some());
628        assert_eq!(back.mode, EncapsulationMode::Adopt);
629    }
630
631    #[test]
632    fn bare_workload_selector_round_trips() {
633        let mut sel = BTreeMap::new();
634        sel.insert("app".into(), "akeyless-gator".into());
635        sel.insert("tier".into(), "prod".into());
636        let s = EncapsulatesSpec {
637            kind: EncapsulationKind {
638                bare_workload: Some(BareWorkload {
639                    namespace: "legacy".into(),
640                    selector: sel,
641                }),
642                ..EncapsulationKind::default()
643            },
644            mode: EncapsulationMode::Observe,
645        };
646        let yaml = serde_yaml::to_string(&s).unwrap();
647        assert!(yaml.contains("bareWorkload:"));
648        assert!(yaml.contains("app: akeyless-gator"));
649        assert!(yaml.contains("mode: Observe"));
650        let back: EncapsulatesSpec = serde_yaml::from_str(&yaml).unwrap();
651        match back.kind.variant().unwrap() {
652            EncapsulationKindVariant::BareWorkload(b) => {
653                assert_eq!(b.selector.len(), 2);
654                assert_eq!(
655                    b.selector.get("app").map(String::as_str),
656                    Some("akeyless-gator")
657                );
658            }
659            other => panic!("expected BareWorkload, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn lisp_round_trip_existing_hr() {
665        let src = r#"
666            (defencapsulates akeyless-adopt
667              :kind (:existing-helm-release
668                     (:namespace    "akeyless"
669                      :name         "akeyless-saas"
670                      :release-name "akeyless-saas-consolidated"))
671              :mode Adopt)
672        "#;
673        let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
674            tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
675        let d = &defs[0];
676        assert_eq!(d.name, "akeyless-adopt");
677        assert_eq!(d.spec.mode, EncapsulationMode::Adopt);
678        let h = d.spec.kind.existing_helm_release.as_ref().unwrap();
679        assert_eq!(h.namespace, "akeyless");
680        assert_eq!(h.release_name, "akeyless-saas-consolidated");
681    }
682
683    #[test]
684    fn lisp_default_mode_is_manage() {
685        // `:mode` omitted ⇒ Manage (Default derive).
686        let src = r#"
687            (defencapsulates greenfield
688              :kind (:existing-kustomization
689                     (:namespace "flux-system"
690                      :name      "openclaw")))
691        "#;
692        let defs: Vec<tatara_lisp::NamedDefinition<EncapsulatesSpec>> =
693            tatara_lisp::compile_named::<EncapsulatesSpec>(src).expect("compile");
694        let d = &defs[0];
695        assert_eq!(d.spec.mode, EncapsulationMode::Manage);
696    }
697
698    // ── closed-set algebra for EncapsulationTarget (ALL × as_str ×
699    //    Display × FromStr × select × EncapsulationKindVariant::target) ─
700
701    /// Construct an `EncapsulationKind` with one slot populated — the
702    /// composable construction table the closed-set property tests
703    /// loop over. Mirrors `single_slot_source` in
704    /// [`crate::export`] in shape.
705    fn single_slot_kind(target: EncapsulationTarget) -> EncapsulationKind {
706        match target {
707            EncapsulationTarget::ExistingHelmRelease => EncapsulationKind {
708                existing_helm_release: Some(ExistingHelmRelease {
709                    namespace: "ns".into(),
710                    name: "hr".into(),
711                    release_name: "rel".into(),
712                }),
713                ..EncapsulationKind::default()
714            },
715            EncapsulationTarget::ExistingKustomization => EncapsulationKind {
716                existing_kustomization: Some(ExistingKustomization {
717                    namespace: "ns".into(),
718                    name: "ks".into(),
719                }),
720                ..EncapsulationKind::default()
721            },
722            EncapsulationTarget::BareWorkload => {
723                let mut sel = BTreeMap::new();
724                sel.insert("app".into(), "x".into());
725                EncapsulationKind {
726                    bare_workload: Some(BareWorkload {
727                        namespace: "ns".into(),
728                        selector: sel,
729                    }),
730                    ..EncapsulationKind::default()
731                }
732            }
733        }
734    }
735
736    /// Construct an `EncapsulationKind` with two slots populated — drives
737    /// the pairwise `Ambiguous` sweep. Composes the single-slot
738    /// constructor on top of itself to keep one source of truth for
739    /// per-variant inner payloads.
740    fn two_slot_kind(a: EncapsulationTarget, b: EncapsulationTarget) -> EncapsulationKind {
741        let ka = single_slot_kind(a);
742        let kb = single_slot_kind(b);
743        EncapsulationKind {
744            existing_helm_release: ka.existing_helm_release.or(kb.existing_helm_release),
745            existing_kustomization: ka.existing_kustomization.or(kb.existing_kustomization),
746            bare_workload: ka.bare_workload.or(kb.bare_workload),
747        }
748    }
749
750    /// Structural well-formedness of [`EncapsulationTarget`] as a
751    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
752    /// testkit lift that pins all three structural invariants (`ALL`
753    /// is non-empty, every variant round-trips through
754    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
755    /// outside the closed set) at ONE call site. Replaces the hand-
756    /// derived `encapsulation_target_all_is_unique_and_complete` +
757    /// `encapsulation_target_roundtrip_via_as_str` + the empty-input
758    /// arm of `unknown_encapsulation_target_errors`. `FromStr`
759    /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`, so
760    /// this helper exercises the same code path the
761    /// `EncapsulationKind::variant` resolver hits when keying on a
762    /// camelCase target name back to the typed target.
763    #[test]
764    fn encapsulation_target_is_well_formed_closed_set() {
765        tatara_closed_set::assert_closed_set_well_formed::<EncapsulationTarget>();
766    }
767
768    /// CANONICAL-KEY CONTRACT: every `EncapsulationTarget::as_str()`
769    /// matches the serde `rename_all = "camelCase"` field name on the
770    /// corresponding `Option<…>` slot of `EncapsulationKind`. A future
771    /// rename of either the struct field OR the `as_str` arm lands here
772    /// at one site, instead of drifting between the typed surface, the
773    /// YAML wire format, and the `EncapsulationKindError::Empty`
774    /// diagnostic. Drives the closed set via `ALL`.
775    #[test]
776    fn encapsulation_target_as_str_matches_field_name() {
777        for t in EncapsulationTarget::ALL {
778            let k = single_slot_kind(t);
779            let yaml = serde_yaml::to_string(&k).expect("serialize");
780            let key = t.as_str();
781            assert!(
782                yaml.contains(&format!("{key}:")),
783                "as_str(={key:?}) for {t:?} not present in serialized YAML:\n{yaml}"
784            );
785        }
786    }
787
788    /// CANONICAL-NAMES PIN: byte-exact camelCase wire-format pin —
789    /// renaming any of these strings IS a wire-format break that fails
790    /// this test FIRST so the rename stays a deliberate decision, not a
791    /// typo. Locks the (variant → operator-facing key) table.
792    #[test]
793    fn encapsulation_target_canonical_names_pinned() {
794        assert_eq!(
795            EncapsulationTarget::ExistingHelmRelease.as_str(),
796            "existingHelmRelease"
797        );
798        assert_eq!(
799            EncapsulationTarget::ExistingKustomization.as_str(),
800            "existingKustomization"
801        );
802        assert_eq!(EncapsulationTarget::BareWorkload.as_str(), "bareWorkload");
803    }
804
805    /// The Display impl IS `as_str` — pinning this lets future callers
806    /// reach for either projection without drift. If a reviewer
807    /// accidentally re-introduces an inline match in Display, this test
808    /// would fail the moment a variant rename touches one site but not
809    /// the other.
810    #[test]
811    fn encapsulation_target_display_matches_as_str() {
812        for t in EncapsulationTarget::ALL {
813            assert_eq!(t.to_string(), t.as_str());
814        }
815    }
816
817    /// `FromStr` rejects strings that aren't in the canonical projection
818    /// — PascalCased / typo / cross-axis-leaked inputs from sibling
819    /// closed-set enums on the same `ProcessSpec` axis (`Manage`,
820    /// `Adopt`, `Observe`, `OnAttested`, …) — and the error echoes the
821    /// input verbatim so the operator-facing diagnostic carries the
822    /// offending value, not a normalized form. `EncapsulationTarget`
823    /// is its own axis, NOT a transparent reflection of any sibling.
824    /// The empty-input arm is pinned by
825    /// [`encapsulation_target_is_well_formed_closed_set`] via the
826    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
827    /// verbatim-echo contract on the [`UnknownEncapsulationTarget`]
828    /// newtype, which the trait's `make_unknown` can't see.
829    #[test]
830    fn unknown_encapsulation_target_errors() {
831        use std::str::FromStr;
832        for bad in [
833            "ExistingHelmRelease",
834            "existing_helm_release",
835            "EXISTINGHELMRELEASE",
836            "helmRelease",
837            "kustomization",
838            "Manage",
839            "Adopt",
840            "Observe",
841            "OnAttested",
842        ] {
843            let err = EncapsulationTarget::from_str(bad).unwrap_err();
844            assert_eq!(err.0, bad, "error payload should echo input verbatim");
845        }
846    }
847
848    /// ROUND-TRIP CONTRACT: every target reaches its borrowed-variant
849    /// view via `select`, and that variant projects back to the same
850    /// target via `EncapsulationKindVariant::target`. A regression that
851    /// misroutes a `select` arm (e.g.
852    /// `Self::ExistingHelmRelease => kind.existing_kustomization
853    /// .as_ref()...`) fails loudly here. Also pins that the resolver
854    /// lands on the same target.
855    #[test]
856    fn encapsulation_target_round_trips_through_variant_target() {
857        for t in EncapsulationTarget::ALL {
858            let k = single_slot_kind(t);
859            let v = t.select(&k).expect("populated slot must select");
860            assert_eq!(v.target(), t, "round-trip failed for {t:?}");
861            assert_eq!(
862                k.variant().expect("exactly-one variant").target(),
863                t,
864                "variant() resolver disagreed on {t:?}"
865            );
866        }
867    }
868
869    /// SELECT-EMPTY CONTRACT: an unpopulated slot returns `None` from
870    /// `select`, for every target. Pairs with the resolver's `Empty`
871    /// path so a future target's slot defaulting wrong (e.g.
872    /// accidentally `Some(Default::default())` instead of `None`) is
873    /// caught here.
874    #[test]
875    fn encapsulation_target_select_returns_none_for_unset_slot() {
876        let empty = EncapsulationKind::default();
877        for t in EncapsulationTarget::ALL {
878            assert!(
879                t.select(&empty).is_none(),
880                "{t:?} reported populated on a default EncapsulationKind"
881            );
882        }
883    }
884
885    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set target list embedded
886    /// in `EncapsulationKindError::Empty` echoes the canonical join of
887    /// every `EncapsulationTarget::as_str()` projection. A variant
888    /// added without updating `ENCAPSULATION_TARGET_LIST` (or a renamed
889    /// variant) shows up here as a mismatch. Mirrors
890    /// `artifact_error_empty_lists_every_kind_in_canonical_order` —
891    /// routes through [`tatara_lisp::ClosedSet::labels_joined`].
892    #[test]
893    fn encapsulation_kind_error_empty_lists_every_target_in_canonical_order() {
894        assert_eq!(
895            <EncapsulationTarget as tatara_closed_set::ClosedSet>::labels_joined("/"),
896            ENCAPSULATION_TARGET_LIST,
897        );
898        // And the diagnostic carries that exact list.
899        let err = EncapsulationKind::default().variant().unwrap_err();
900        assert_eq!(
901            err,
902            EncapsulationKindError::Empty(ENCAPSULATION_TARGET_LIST)
903        );
904    }
905
906    /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
907    /// resolver yields `Ambiguous`, exhaustively across every pair in
908    /// `ALL × ALL` (excluding the diagonal). A future asymmetry where
909    /// one slot would silently shadow another (e.g. an `if-let` chain
910    /// re-introducing first-wins ordering) is caught here.
911    #[test]
912    fn encapsulation_kind_two_slots_is_ambiguous_across_every_pair() {
913        for a in EncapsulationTarget::ALL {
914            for b in EncapsulationTarget::ALL {
915                if a == b {
916                    continue;
917                }
918                let k = two_slot_kind(a, b);
919                assert_eq!(
920                    k.variant().unwrap_err(),
921                    EncapsulationKindError::Ambiguous,
922                    "({a:?}, {b:?}) should resolve Ambiguous"
923                );
924            }
925        }
926    }
927
928    // Per-implementor `unknown_X_message_matches_substrate_convention`
929    // tests removed — clause (5) of
930    // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
931    // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
932    // generically (called above on `EncapsulationTarget` /
933    // `EncapsulationMode` through their `*_is_well_formed_closed_set`
934    // sites). The `SET_LABEL` projection is pinned independently by
935    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
936    // together the two contracts guarantee the operator-facing
937    // diagnostic without needing per-enum literal pins.
938}