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