Skip to main content

tatara_process/
intent.rs

1//! Intent — where the rendered artifacts come from.
2//!
3//! Exactly one field on `Intent` must be set. The reconciler's RENDER phase
4//! selects a driver based on which variant is present:
5//!   - `nix`:        tatara-engine `nix_eval` → resources
6//!   - `flux`:       pass through an existing `GitRepository`
7//!   - `lisp`:       tatara-lisp reader + macroexpander → resources
8//!   - `container`:  emit Deployment/StatefulSet/etc directly (no Helm)
9//!   - `aplicacao`:  emit a FluxCD `HelmRelease` for a pleme-io typed
10//!                   Aplicacao chart (e.g. `lareira-demo-app`).
11//!                   This is the canonical handoff from caixa-shaped
12//!                   declarations to in-cluster reconciliation.
13//!   - `guest`:      tatara-hospedeiro supervises a Linux VM or WASM
14//!                   component. See `tatara/docs/declarative-guests.md`.
15//!                   The GuestSpec itself is type-erased here (JSON value)
16//!                   so tatara-process stays decoupled from tatara-vm;
17//!                   hospedeiro re-parses the value as GuestSpec on boot.
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22
23/// Intent — exactly one variant should be populated.
24#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct Intent {
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub nix: Option<NixIntent>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub flux: Option<FluxIntent>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub lisp: Option<LispIntent>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub container: Option<ContainerIntent>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub aplicacao: Option<AplicacaoIntent>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub guest: Option<GuestIntent>,
39}
40
41/// Enum view over the populated variant — convenience for the reconciler.
42#[derive(Clone, Debug)]
43pub enum IntentVariant<'a> {
44    Nix(&'a NixIntent),
45    Flux(&'a FluxIntent),
46    Lisp(&'a LispIntent),
47    Container(&'a ContainerIntent),
48    Aplicacao(&'a AplicacaoIntent),
49    Guest(&'a GuestIntent),
50}
51
52impl IntentVariant<'_> {
53    /// Reverse projection — every borrowed variant knows its
54    /// `IntentKind` discriminator. Pairs with `IntentKind::select`
55    /// so `IntentKind::select(intent).map(|v| v.kind())` round-trips
56    /// the closed set; pinned by the substrate testkit
57    /// [`crate::tagged_union::assert_variant_round_trip`] shared
58    /// across every `<T: TaggedUnion>` implementor. The inherent
59    /// method stays load-bearing (the `.kind()` calling convention
60    /// pre-dates the trait lift; no consumer needs `use
61    /// crate::tagged_union::VariantKind` to reach the reverse
62    /// projection) while the trait impl below delegates to this body
63    /// as the ground-truth arm-to-Kind mapping.
64    pub fn kind(&self) -> IntentKind {
65        match self {
66            Self::Nix(_) => IntentKind::Nix,
67            Self::Flux(_) => IntentKind::Flux,
68            Self::Lisp(_) => IntentKind::Lisp,
69            Self::Container(_) => IntentKind::Container,
70            Self::Aplicacao(_) => IntentKind::Aplicacao,
71            Self::Guest(_) => IntentKind::Guest,
72        }
73    }
74
75    /// Canonical attestation-pillar bytes for the populated variant —
76    /// `serde_json::to_vec` on the inner reference, with an empty
77    /// fallback that matches the pre-lift Observe-mode shape in
78    /// `tatara-reconciler::render`. ONE site owns the per-variant
79    /// serialization so adding a 7th variant requires only the
80    /// arm here, not the parallel match the pre-lift Observe arm
81    /// carried.
82    pub fn canonical_bytes(&self) -> Vec<u8> {
83        match self {
84            Self::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
85            Self::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
86            Self::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
87            Self::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
88            Self::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
89            Self::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
90        }
91    }
92}
93
94impl crate::tagged_union::VariantKind<IntentKind> for IntentVariant<'_> {
95    fn variant_kind(&self) -> IntentKind {
96        self.kind()
97    }
98}
99
100/// Closed-set discriminator over `Intent`'s six tagged-union slots.
101/// Single source of truth that drives `Intent::variant`'s ambiguity
102/// + emptiness resolver, the `IntentError::Empty` message, and the
103/// reverse `IntentVariant::kind` projection. Adding a 7th intent
104/// variant lands at one `ALL` entry + one `as_str` arm + one
105/// `select` arm + one `IntentVariant::kind` arm — exhaustively
106/// checked by the compiler.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
108#[closed_set(via = "as_str", generate_unknown, display)]
109pub enum IntentKind {
110    Nix,
111    Flux,
112    Lisp,
113    Container,
114    Aplicacao,
115    Guest,
116}
117
118impl IntentKind {
119    /// The closed set of intent kinds — single source of truth that
120    /// drives `Intent::variant`'s sweep so a variant added without
121    /// an `ALL` entry never reaches the resolver.
122    pub const ALL: [Self; 6] = [
123        Self::Nix,
124        Self::Flux,
125        Self::Lisp,
126        Self::Container,
127        Self::Aplicacao,
128        Self::Guest,
129    ];
130
131    /// Canonical lower-case wire-format key — matches the serde
132    /// `rename_all = "camelCase"` field name on `Intent`. The
133    /// `IntentError::Empty` message composes the human-readable
134    /// list from this projection so a new variant lands in the
135    /// operator-facing diagnostic automatically via the `ALL`
136    /// sweep, not via hand-maintained error-string drift.
137    pub const fn as_str(self) -> &'static str {
138        match self {
139            Self::Nix => "nix",
140            Self::Flux => "flux",
141            Self::Lisp => "lisp",
142            Self::Container => "container",
143            Self::Aplicacao => "aplicacao",
144            Self::Guest => "guest",
145        }
146    }
147
148    /// Project an `Intent` borrow into the optional typed variant
149    /// view for this kind. Returns `None` iff the matching slot is
150    /// `None`. Composes the closed-set sweep `Intent::variant`
151    /// loops over.
152    pub fn select<'a>(self, intent: &'a Intent) -> Option<IntentVariant<'a>> {
153        match self {
154            Self::Nix => intent.nix.as_ref().map(IntentVariant::Nix),
155            Self::Flux => intent.flux.as_ref().map(IntentVariant::Flux),
156            Self::Lisp => intent.lisp.as_ref().map(IntentVariant::Lisp),
157            Self::Container => intent.container.as_ref().map(IntentVariant::Container),
158            Self::Aplicacao => intent.aplicacao.as_ref().map(IntentVariant::Aplicacao),
159            Self::Guest => intent.guest.as_ref().map(IntentVariant::Guest),
160        }
161    }
162}
163
164crate::declare_tagged_union_error! {
165    pub IntentError,
166    empty = "intent has no variant set (one of {0} required)",
167    ambiguous = "intent has multiple variants set; exactly one required",
168}
169
170/// Slash-joined list of every `IntentKind::as_str()` — composed once
171/// at compile time so `IntentError::Empty`'s diagnostic carries the
172/// closed-set summary without per-variant string drift. Pinned against
173/// the canonical [`tatara_lisp::ClosedSet::labels_joined`] projection
174/// by `intent_error_empty_lists_every_kind_in_canonical_order`, so a
175/// regression that drifts this `&'static str` constant from the
176/// `IntentKind::ALL × as_str` composition fails-loudly at the test
177/// site without per-variant inline materialization.
178pub(crate) const INTENT_KIND_LIST: &str = "nix/flux/lisp/container/aplicacao/guest";
179
180// `impl FromStr for IntentKind` +
181// `impl tatara_lisp::ClosedSet for IntentKind` +
182// `impl fmt::Display for IntentKind` +
183// `pub struct UnknownIntentKind(pub String)` are all generated by
184// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
185// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
186// enum declaration above. `label` delegates to the inherent
187// `IntentKind::as_str` — the camelCase wire-vocabulary projection
188// stays load-bearing (matches the serde `rename_all = "camelCase"`
189// field names on `Intent` AND the `IntentVariant::canonical_bytes`
190// per-variant arm), while generic `T: ClosedSet` consumers reach the
191// STABLE workspace-wide name (`label`). The auto-derived carrier
192// label "intent kind" matches the substrate-wide
193// `#[error("unknown intent kind: {0}")]` shape every sibling
194// closed-set carrier across `tatara-process` renders verbatim.
195// Symmetric to [`crate::intent::WorkloadKind`] (the workload-axis
196// sibling on the same `ProcessSpec` slice) and every other
197// `#[derive(DeriveClosedSet)]` implementor across the crate.
198
199crate::declare_tagged_union_impls! {
200    parent = Intent,
201    kind = IntentKind,
202    variant = IntentVariant,
203    error = IntentError,
204    kind_list = INTENT_KIND_LIST,
205}
206
207/// Nix-sourced intent — tatara-engine's nix_eval driver produces resources.
208#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct NixIntent {
211    /// Flake reference, e.g., `github:pleme-io/k8s?dir=shared/infrastructure`.
212    pub flake_ref: String,
213    /// Attribute path within the flake (e.g., `observability`).
214    pub attribute: String,
215    /// Target system. Defaults to the controller host's system.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub system: Option<String>,
218    /// Attic cache to push the resulting store path into.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub attic_cache: Option<String>,
221    /// Additional `nix build` arguments (e.g., `["--impure"]`).
222    #[serde(default)]
223    pub extra_args: Vec<String>,
224    /// Delegate the actual build to a sibling NixBuild CRD
225    /// (bridges to tatara-operator NATS bare-metal builder path).
226    #[serde(default)]
227    pub delegate_to_nix_build: bool,
228}
229
230/// FluxCD passthrough intent — reuse an existing GitRepository.
231#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "camelCase")]
233pub struct FluxIntent {
234    /// Name of an existing `GitRepository` (typically in `flux-system`).
235    pub git_repository: String,
236    /// Path inside the repository that the Kustomization will apply.
237    pub path: String,
238    /// Optional namespace of the GitRepository CR (defaults to `flux-system`).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub git_repository_namespace: Option<String>,
241    /// Optional target namespace for the emitted Kustomization.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub target_namespace: Option<String>,
244    /// SOPS decryption — defaults to true to match pleme-io conventions.
245    #[serde(default = "default_true")]
246    pub decrypt_sops: bool,
247    /// If set, additionally emit a HelmRelease for this chart.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub helm_chart: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub helm_values: Option<BTreeMap<String, serde_json::Value>>,
252}
253
254fn default_true() -> bool {
255    true
256}
257
258/// Lisp-sourced intent — tatara-lisp reader + macroexpander produces resources.
259#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
260#[serde(rename_all = "camelCase")]
261pub struct LispIntent {
262    /// Raw S-expression source, OR `include:<path>` / `configmap:<name>/<key>` pointer.
263    pub source: String,
264    /// Reader dialect / version tag.
265    #[serde(default = "default_reader")]
266    pub reader: String,
267    /// Macro form version.
268    #[serde(default = "default_version")]
269    pub version: String,
270    /// Symbols injected into the reader env (e.g., `cluster`, `region`).
271    #[serde(default)]
272    pub bindings: BTreeMap<String, serde_json::Value>,
273}
274
275fn default_reader() -> String {
276    "tatara-lisp".to_string()
277}
278fn default_version() -> String {
279    "v1".to_string()
280}
281
282/// Aplicacao intent — emit a FluxCD `HelmRelease` for a pleme-io
283/// typed Aplicacao chart. The chart owns its own sub-chart DAG;
284/// the reconciler only watches `HelmRelease.status.conditions[type=Ready]`.
285///
286/// This is the canonical handoff from caixa `(defaplicacao …)` declarations
287/// (which the typescape renders to this Intent) into in-cluster
288/// reconciliation. Closed-loop ephemeral test environments use this
289/// variant with `:lifetime :ephemeral` on the surrounding ProcessSpec.
290///
291/// Example (Lisp):
292/// ```lisp
293/// :intent (:aplicacao
294///           (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
295///            :version "0.5.5"
296///            :profile "all-in-one"
297///            :values-overlay (:cluster (:name "ephemeral-test-01")
298///                             :persistence false
299///                             :compliance (:overlays []))))
300/// ```
301#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
302#[serde(rename_all = "camelCase")]
303pub struct AplicacaoIntent {
304    /// Helm chart reference. OCI (`oci://…`) or repo-relative (`pleme-io/lareira-demo-app`).
305    pub chart_ref: String,
306    /// Chart version (Helm semver constraint; `">=0.5.5"` allowed).
307    pub version: String,
308    /// Architecture profile from the chart's `values/*.yaml` family
309    /// (e.g. `all-in-one`, `saas-internal`).
310    /// Leave empty to use chart defaults.
311    #[serde(default, skip_serializing_if = "String::is_empty")]
312    pub profile: String,
313    /// Typed values overlay merged on top of the profile.
314    /// Free-form JSON to keep tatara-process decoupled from chart schemas.
315    #[serde(default)]
316    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
317    pub values_overlay: serde_json::Value,
318    /// HelmRelease name override. Defaults to the Process's PID-derived name.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub release_name: Option<String>,
321    /// Target namespace for the chart. Defaults to the Process's namespace.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub target_namespace: Option<String>,
324    /// Install timeout (`humantime` duration). Empty = chart-controller default.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub install_timeout: Option<String>,
327}
328
329/// Workspace-wide default for the `timeout` slot on a Flux
330/// `HelmRelease.spec.{install,upgrade}` block, applied when the
331/// operator did not populate [`AplicacaoIntent::install_timeout`].
332/// Load-bearing on the reconciler's Helm-driven RENDER surface —
333/// [`AplicacaoIntent::helm_lifecycle_policy`] substitutes this exact
334/// string, and the reconciler's `render_aplicacao` byte-installs
335/// the resulting policy into both install AND upgrade slots.
336pub const HELM_LIFECYCLE_DEFAULT_TIMEOUT: &str = "25m";
337
338/// Workspace-wide default for the `remediation.retries` slot on a
339/// Flux `HelmRelease.spec.{install,upgrade}` block. Constant across
340/// both slots today; a future two-slot split (e.g. distinct retry
341/// budgets for a first install vs a rolling upgrade) lands as two
342/// consts here + a two-slot [`HelmLifecyclePolicy`] shape, not at
343/// the render callsite.
344pub const HELM_LIFECYCLE_DEFAULT_RETRIES: u8 = 3;
345
346/// Workspace-wide default for the reconcile-loop cadence on both Flux
347/// resources a Helm-driven `AplicacaoIntent` publishes today: the
348/// `OCIRepository.spec.interval` on the source side (how often the
349/// source-controller re-pulls the chart from OCI) and the
350/// `HelmRelease.spec.interval` on the release side (how often the
351/// helm-controller re-reconciles the release against the chart).
352/// The fleet convention ties both cadences to the same `5m` string
353/// today, so the substrate exposes ONE named const rather than two
354/// literals sprayed across `render_aplicacao`.
355///
356/// Peer to [`HELM_LIFECYCLE_DEFAULT_TIMEOUT`] on the same
357/// AplicacaoIntent-facing "workspace-wide Flux default" axis. A
358/// future per-slot divergence (`SOURCE_INTERVAL` vs `RELEASE_INTERVAL`
359/// as two consts, or a two-slot method returning a
360/// `FluxReconcileIntervals { source, release }` shape) lands here,
361/// NOT at the two render callsites.
362///
363/// Load-bearing wire-format string: the byte-exact `5m` shape is
364/// what the Flux source- and helm-controllers parse via `humantime`;
365/// a regression that renamed it to any other duration would silently
366/// throttle or hammer every Helm-driven Process's reconciliation
367/// loop. Pinned at
368/// [`tests::flux_helm_default_interval_is_pinned_to_5m`].
369pub const FLUX_HELM_DEFAULT_INTERVAL: &str = "5m";
370
371/// Typed shape of one Flux `HelmRelease.spec.{install,upgrade}` slot
372/// — the substrate's projection of the "how long may Helm take, and
373/// how many retries after a failed run" contract every Helm-driven
374/// Process publishes on both slots. Pre-lift the reconciler's
375/// `render_aplicacao` hand-authored the shape via TWO adjacent
376/// identical `json!({"timeout": …, "remediation": {"retries": …}})`
377/// blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
378/// — one for install, one for upgrade, each restating the same
379/// three-slot literal with the same `Option::unwrap_or_else` fallback
380/// on the timeout. Post-lift the shape lives at ONE named typed
381/// struct here whose serde projection matches Flux HelmRelease v2's
382/// `install` / `upgrade` block schema byte-identically, and the
383/// reconciler composes both slots off ONE
384/// [`AplicacaoIntent::helm_lifecycle_policy`] call.
385///
386/// A future addition — a `wait: bool` slot, a `crds:
387/// CreateReplace` slot, a `disableOpenAPIValidation: bool` slot,
388/// a two-slot split that lets install carry a longer timeout than
389/// upgrade — lands at ONE struct here and every downstream
390/// consumer (the render surface, snapshot tests, an operator-
391/// facing dashboard column, a future validating webhook) inherits
392/// the upgrade mechanically.
393#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
394pub struct HelmLifecyclePolicy {
395    /// Chart-controller timeout (`humantime` duration). Set from
396    /// [`AplicacaoIntent::install_timeout`] when present; otherwise
397    /// [`HELM_LIFECYCLE_DEFAULT_TIMEOUT`].
398    pub timeout: String,
399    /// Retry budget for the slot.
400    pub remediation: HelmRemediationPolicy,
401}
402
403/// Typed shape of one `HelmLifecyclePolicy::remediation` slot.
404/// A named struct rather than an inline `{retries: u8}` map so
405/// downstream consumers can talk about "one Helm remediation
406/// policy" as a nameable handle rather than an unnamed nested
407/// object.
408#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
409pub struct HelmRemediationPolicy {
410    /// Number of times Flux's helm-controller retries a failed
411    /// install / upgrade before surfacing the failure to the parent
412    /// Process's boundary evaluator.
413    pub retries: u8,
414}
415
416impl HelmLifecyclePolicy {
417    /// The workspace-wide default policy — used when the operator
418    /// omitted [`AplicacaoIntent::install_timeout`]. Named projection
419    /// of the two `HELM_LIFECYCLE_DEFAULT_*` consts so a future
420    /// consumer wanting "the substrate's fresh-out-of-the-box Helm
421    /// lifecycle policy" pulls the pair through ONE call rather than
422    /// composing the struct by hand at every callsite.
423    pub fn workspace_default() -> Self {
424        Self {
425            timeout: HELM_LIFECYCLE_DEFAULT_TIMEOUT.to_string(),
426            remediation: HelmRemediationPolicy {
427                retries: HELM_LIFECYCLE_DEFAULT_RETRIES,
428            },
429        }
430    }
431}
432
433impl AplicacaoIntent {
434    /// Chart-pointer-only composer — the canonical minimal
435    /// `AplicacaoIntent` every fixture and default-shape callsite
436    /// restated pre-lift by binding only `(chart_ref, version)` and
437    /// leaving every remaining slot at its K8s-schema-default value
438    /// (`profile = ""`, `values_overlay = Value::Null`,
439    /// `release_name = target_namespace = install_timeout = None`).
440    ///
441    /// Pre-lift the 7-slot struct-literal was hand-authored at 14
442    /// workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
443    /// duplication threshold, each restating the SAME 5-slot default
444    /// tail after a caller-varying `(chart_ref, version)` pair:
445    ///
446    /// * `tatara-process` — 10 sites across `lib.rs` (3× `empty_template`
447    ///   in the ephemeral-spec / matrix / observer test blocks),
448    ///   `lifetime_clock.rs` (2× the ephemeral / permanent Process
449    ///   composer helpers), `tagged_union.rs` (1× the
450    ///   `IntentKind::Aplicacao` sample-intent arm), `pool.rs` (1×
451    ///   `empty_template`), and `intent.rs` (3× the `helm_intent`
452    ///   helper + the `aplicacao_plus_flux_is_ambiguous` fixture + the
453    ///   `IntentKind::Aplicacao` sample-intent-for arm).
454    /// * `tatara-pool-reconciler` — 4 sites across `router.rs`,
455    ///   `pool_decide.rs`, `desired.rs`, `allocation_decide.rs`
456    ///   (all `empty_template` fixtures seeding the pool + allocation
457    ///   convergence-decision test batteries).
458    /// * `tatara-reconciler` — 1 site in `render.rs`
459    ///   (`helmrepository_chartref_for_non_oci` production-shape
460    ///   fixture stamping the non-OCI chart-ref render path).
461    ///
462    /// All 14 sites walked the SAME 5-slot default tail — differing
463    /// only in the caller-varying `chart_ref` / `version` values
464    /// (`"oci://x"` / `"1"` on the majority test-fixture slice,
465    /// `"oci://ghcr.io/x"` / `"0.1.0"` on the `IntentKind` sample,
466    /// `"pleme-io/lareira-demo-app"` / `"0.5.5"` on the non-OCI
467    /// render pin). Post-lift each callsite reads
468    /// `AplicacaoIntent::chart_only(chart, version)` and the 5-slot
469    /// default tail lives at ONE substrate owner.
470    ///
471    /// The `impl Into<String>` argument form matches the pre-lift
472    /// call shape — every site that spelled `"oci://x".into()` +
473    /// `"1".into()` in the struct-literal continues to compile
474    /// unchanged, and callers with a live `String` (e.g. reading
475    /// from a caller-supplied fixture parameter) pass it through
476    /// without a `.to_string()` re-wrap.
477    ///
478    /// Return-form axis: `AplicacaoIntent` — the owned typed value
479    /// every consumer's downstream `Intent { aplicacao: Some(...), ..
480    /// }` / `EphemeralSpec { aplicacao: ..., .. }` binding stamps
481    /// verbatim, matching the pre-lift 7-slot struct-literal's return
482    /// shape exactly. The five default-tail slots reify the K8s
483    /// schema's own defaults (`profile` empty ⇒ chart profile default;
484    /// `values_overlay = Null` ⇒ pass-through; three `None` slots ⇒
485    /// server / chart-computed) so no consumer inherits a semantic
486    /// change from the lift.
487    ///
488    /// A future normalization of the "minimal AplicacaoIntent" default
489    /// — an added struct field with its own K8s-schema default, a
490    /// tightening of a `None` slot to a substrate-owned default value,
491    /// a per-workspace-default `install_timeout` override — lands at
492    /// THIS ONE substrate primitive and every downstream fixture /
493    /// default-shape consumer inherits the upgrade mechanically — no
494    /// per-site edit at any of the 14 listed callers or at future
495    /// consumers (a new pool-shard-flavor fixture, a new intent-axis
496    /// convergence probe, a per-tenant AplicacaoIntent minimal seed).
497    ///
498    /// Peer to [`Self::helm_lifecycle_policy`] +
499    /// [`Self::flux_reconcile_interval`] on the (COMPOSE, DERIVE)
500    /// axis: `chart_only` is the WRITE-side composer (stamp a minimal
501    /// `AplicacaoIntent`); the lifecycle + interval methods are the
502    /// READ-side derivers (project a substrate-default Flux policy /
503    /// cadence off an `AplicacaoIntent`). The three primitives
504    /// partition the `AplicacaoIntent` compose × derive surface at
505    /// the substrate.
506    ///
507    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
508    /// the 5-slot default tail recurred at 14 hand-authored sites
509    /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning
510    /// three workspace crates, and is lifted onto the ONE workspace-
511    /// wide substrate owner here). THEORY.md §II.1 invariant 5
512    /// (composition preserves proofs — the pin block below binds the
513    /// primitive at fail-before-pass-after granularity so a
514    /// regression that drifted any of the five default-tail slots
515    /// surfaces at THESE pins rather than as silent fixture skew
516    /// across the 14 downstream consumers).
517    #[must_use]
518    pub fn chart_only(chart_ref: impl Into<String>, version: impl Into<String>) -> Self {
519        Self {
520            chart_ref: chart_ref.into(),
521            version: version.into(),
522            profile: String::new(),
523            values_overlay: serde_json::Value::Null,
524            release_name: None,
525            target_namespace: None,
526            install_timeout: None,
527        }
528    }
529
530    /// Effective HelmRelease name — the operator-supplied
531    /// [`Self::release_name`] override when set, else the caller-
532    /// supplied fallback (canonically the Process's PID-derived name
533    /// at the reconciler's `render_aplicacao` site, and the enclosing
534    /// matrix env's `NamedEphemeral.name` at the matrix
535    /// `breathe_bands` site — both are the Process's own `name`).
536    ///
537    /// Pre-lift the pattern was hand-authored at 2 workspace-wide
538    /// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
539    /// each restating the SAME `Option::clone().unwrap_or_else(||
540    /// fallback.into())` chain differing only in the caller-varying
541    /// fallback slot:
542    ///
543    /// * `tatara-reconciler::render::render_aplicacao` — the
544    ///   `HelmRelease.spec.releaseName` slot on the Flux-owned
545    ///   release the reconciler emits per Helm-driven Process.
546    /// * `tatara-process::matrix::EnvMatrixSpec::breathe_bands` —
547    ///   the `spec.targetRef.name` slot on the breathe Band CRs the
548    ///   matrix sweep emits per generated env × dimension.
549    ///
550    /// Post-lift both sites read `a.release_name_or(fallback)` and
551    /// the fallback-composition shape lives at ONE substrate owner.
552    /// A future tightening — a workspace-wide default seeded off the
553    /// PID, a fallback shape derived off `Self::chart_ref`, a
554    /// validation that the fallback is a DNS-1123 label — lands at
555    /// THIS ONE primitive.
556    ///
557    /// Peer to [`Self::target_namespace_or`] on the same (release,
558    /// namespace) axis: both project one operator-optional
559    /// override-or-fallback slot the reconciler + matrix consume in
560    /// lock-step.
561    #[must_use]
562    pub fn release_name_or(&self, fallback: &str) -> String {
563        self.release_name.clone().unwrap_or_else(|| fallback.into())
564    }
565
566    /// Effective HelmRelease target namespace — the operator-supplied
567    /// [`Self::target_namespace`] override when set, else the caller-
568    /// supplied fallback (canonically the Process's own namespace at
569    /// the reconciler's `render_aplicacao` site, and the enclosing
570    /// matrix env's `NamedEphemeral.name` at the matrix
571    /// `breathe_bands` site).
572    ///
573    /// Peer to [`Self::release_name_or`] — same substrate-owner
574    /// motivation, same 2-site collapse. Post-lift both consumers
575    /// read `a.target_namespace_or(fallback)` and the
576    /// `Option::clone().unwrap_or_else(|| fallback.into())` shape
577    /// lives at ONE primitive here.
578    #[must_use]
579    pub fn target_namespace_or(&self, fallback: &str) -> String {
580        self.target_namespace
581            .clone()
582            .unwrap_or_else(|| fallback.into())
583    }
584
585    /// Derive the Flux `HelmRelease.spec.{install,upgrade}` policy
586    /// this intent publishes on BOTH slots. Pre-lift the reconciler's
587    /// `render_aplicacao` restated the shape by hand via two adjacent
588    /// identical `json!` blocks (install and upgrade); post-lift both
589    /// slots ride through this ONE composer. A future two-slot split
590    /// (distinct install vs upgrade policies) lands as a two-method
591    /// pair here, not at the render callsite.
592    pub fn helm_lifecycle_policy(&self) -> HelmLifecyclePolicy {
593        HelmLifecyclePolicy {
594            timeout: self
595                .install_timeout
596                .clone()
597                .unwrap_or_else(|| HELM_LIFECYCLE_DEFAULT_TIMEOUT.to_string()),
598            remediation: HelmRemediationPolicy {
599                retries: HELM_LIFECYCLE_DEFAULT_RETRIES,
600            },
601        }
602    }
603
604    /// Derive the Flux reconcile-loop cadence this intent publishes on
605    /// BOTH `OCIRepository.spec.interval` (source-controller poll) and
606    /// `HelmRelease.spec.interval` (helm-controller re-reconcile).
607    /// Pre-lift the reconciler's `render_aplicacao` restated the value
608    /// via two adjacent hand-authored `"5m"` string literals past the
609    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold; post-lift both
610    /// slots ride through this ONE composer. A future divergence
611    /// (distinct per-slot cadences, a per-intent override field, a
612    /// two-slot method returning a `FluxReconcileIntervals` shape)
613    /// lands at ONE method here, not at the render callsites.
614    ///
615    /// Sibling composer to [`Self::helm_lifecycle_policy`]: both
616    /// return the substrate-default shape a Helm-driven Process
617    /// publishes on the Flux resources `render_aplicacao` emits,
618    /// keyed off the same `AplicacaoIntent`.
619    pub fn flux_reconcile_interval(&self) -> String {
620        FLUX_HELM_DEFAULT_INTERVAL.to_string()
621    }
622}
623
624/// Container intent — direct Deployment/StatefulSet/etc, no Helm.
625#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
626#[serde(rename_all = "camelCase")]
627pub struct ContainerIntent {
628    pub image: String,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub replicas: Option<i32>,
631    #[serde(default)]
632    pub command: Vec<String>,
633    #[serde(default)]
634    pub args: Vec<String>,
635    #[serde(default)]
636    pub env: BTreeMap<String, String>,
637    #[serde(default)]
638    pub workload_kind: WorkloadKind,
639}
640
641/// K8s workload kind the `container` intent renders into. PascalCase
642/// values match the K8s `kind:` field on the emitted manifest verbatim,
643/// so `as_str` doubles as the canonical `kind:` projection at render time.
644#[derive(
645    Clone,
646    Copy,
647    Debug,
648    PartialEq,
649    Eq,
650    Hash,
651    Serialize,
652    Deserialize,
653    JsonSchema,
654    Default,
655    tatara_closed_set::DeriveClosedSet,
656)]
657#[serde(rename_all = "PascalCase")]
658#[closed_set(via = "as_str", generate_unknown, display)]
659pub enum WorkloadKind {
660    #[default]
661    Deployment,
662    StatefulSet,
663    DaemonSet,
664    Job,
665    CronJob,
666}
667
668impl WorkloadKind {
669    /// The closed set of workload kinds — single source of truth that
670    /// drives the `as_str` / Display / `FromStr` triad and the typed
671    /// `api_version` / `is_batch` projections. Adding a sixth variant
672    /// lands at one `ALL` entry + one `as_str` arm + one arm in each
673    /// projection — exhaustively checked by the compiler (the `[Self; 5]`
674    /// array literal forces the arity).
675    ///
676    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
677    /// [`crate::encapsulates::EncapsulationMode::ALL`],
678    /// [`crate::export::ExportTrigger::ALL`],
679    /// [`crate::export::ReportFormat::ALL`],
680    /// [`crate::lifetime::TeardownPolicy::ALL`],
681    /// [`crate::intent::IntentKind::ALL`],
682    /// [`crate::lifetime::LifetimeKind::ALL`],
683    /// [`crate::boundary::ConditionKind::ALL`],
684    /// [`crate::phase::ProcessPhase::ALL`],
685    /// [`crate::signal::ProcessSignal::ALL`].
686    pub const ALL: [Self; 5] = [
687        Self::Deployment,
688        Self::StatefulSet,
689        Self::DaemonSet,
690        Self::Job,
691        Self::CronJob,
692    ];
693
694    /// Canonical PascalCase wire-format projection — matches the serde
695    /// `rename_all = "PascalCase"` output verbatim AND the K8s manifest
696    /// `kind:` field the `container` intent's future renderer will emit.
697    /// Used by Display (single source of truth), by `FromStr` to identify
698    /// the variant from its annotation / status-field representation, and
699    /// by operator-facing reason strings without reaching for `{:?}` Debug
700    /// formatting. Pinned by `workload_kind_as_str_matches_serde`.
701    pub const fn as_str(self) -> &'static str {
702        match self {
703            Self::Deployment => "Deployment",
704            Self::StatefulSet => "StatefulSet",
705            Self::DaemonSet => "DaemonSet",
706            Self::Job => "Job",
707            Self::CronJob => "CronJob",
708        }
709    }
710
711    /// Canonical K8s `apiVersion:` projection — `apps/v1` for the
712    /// long-running workload trio, `batch/v1` for the batch pair.
713    /// Single source of truth for the apiVersion the `container` intent
714    /// renderer will stamp on the emitted manifest; pinned by
715    /// `workload_kind_projection_truth_table` so a future variant lands
716    /// at one arm here, not at every render site that previously
717    /// hand-rolled `match kind { Job | CronJob => "batch/v1", _ => … }`.
718    ///
719    /// Closed-set match (not `matches!`) so adding a sixth variant
720    /// triggers the compiler's exhaustiveness check at this site
721    /// rather than silently defaulting to either group.
722    pub const fn api_version(self) -> &'static str {
723        match self {
724            Self::Deployment | Self::StatefulSet | Self::DaemonSet => "apps/v1",
725            Self::Job | Self::CronJob => "batch/v1",
726        }
727    }
728
729    /// True iff the workload kind is a batch (terminating) workload —
730    /// `Job` or `CronJob`. Drives the future container renderer's
731    /// decision between persistent / one-shot retry semantics and lets
732    /// the lifetime clock distinguish "naturally terminates" from "runs
733    /// until SIGTERM" without re-deriving the partition from
734    /// `api_version() == "batch/v1"`.
735    ///
736    /// Closed-set match (not `matches!`) so adding a sixth variant
737    /// triggers the compiler's exhaustiveness check at this site.
738    pub const fn is_batch(self) -> bool {
739        match self {
740            Self::Job | Self::CronJob => true,
741            Self::Deployment | Self::StatefulSet | Self::DaemonSet => false,
742        }
743    }
744}
745
746// `impl FromStr for WorkloadKind` +
747// `impl tatara_lisp::ClosedSet for WorkloadKind` +
748// `impl fmt::Display for WorkloadKind` +
749// `pub struct UnknownWorkloadKind(pub String)` are all generated by
750// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
751// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
752// enum declaration above. `label` delegates to the inherent
753// `WorkloadKind::as_str` — the PascalCase wire-vocabulary projection
754// stays load-bearing (matches the serde `rename_all = "PascalCase"`
755// output AND the K8s manifest `kind:` field verbatim), while generic
756// `T: ClosedSet` consumers reach the STABLE workspace-wide name
757// (`label`). The auto-derived carrier label "workload kind" matches
758// the prior hand-rolled `#[error("unknown workload kind: {0}")]`
759// annotation byte-for-byte. Symmetric to every other
760// `#[derive(DeriveClosedSet)]` implementor across the crate.
761
762/// Guest intent — the Process is a Linux VM or WASM component supervised
763/// by `tatara-hospedeiro`. See `tatara/docs/declarative-guests.md`.
764///
765/// The actual `GuestSpec` is stored as a serde JSON value to keep
766/// `tatara-process` decoupled from `tatara-vm`. Hospedeiro re-parses
767/// the value as the concrete `tatara_vm::GuestSpec` at boot time; a
768/// round-trip test on the tatara-vm side guarantees the shape.
769#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
770#[serde(rename_all = "camelCase")]
771pub struct GuestIntent {
772    /// The (defguest …) spec as JSON. Shape matches `tatara_vm::GuestSpec`.
773    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
774    pub spec: serde_json::Value,
775
776    /// Where to write per-guest state on the host (logs, socket, PID file).
777    /// Defaults to `~/.local/state/tatara/guests/<name>/`.
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub state_dir: Option<String>,
780
781    /// Whether hospedeiro is allowed to pull guest artifacts from a remote
782    /// transport (Attic, ssh-ng) if not already present locally. The
783    /// default is taken from the GuestSpec's `buildOn` field; setting
784    /// this explicitly overrides at the intent layer.
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub allow_remote_build: Option<bool>,
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn empty_intent_errors() {
795        let i = Intent::default();
796        match i.variant().unwrap_err() {
797            IntentError::Empty(list) => assert_eq!(list, INTENT_KIND_LIST),
798            other => panic!("expected Empty, got {other:?}"),
799        }
800    }
801
802    #[test]
803    fn exactly_one_ok() {
804        let i = Intent {
805            nix: Some(NixIntent {
806                flake_ref: "github:a/b".into(),
807                attribute: "x".into(),
808                system: None,
809                attic_cache: None,
810                extra_args: vec![],
811                delegate_to_nix_build: false,
812            }),
813            ..Intent::default()
814        };
815        assert!(matches!(i.variant().unwrap(), IntentVariant::Nix(_)));
816    }
817
818    /// AMBIGUOUS-PATH CONTRACT: when two slots are populated the
819    /// resolver yields `Ambiguous`, exhaustively across every pair in
820    /// `ALL × ALL` (excluding the diagonal). Routes through the
821    /// substrate primitive
822    /// [`crate::tagged_union::assert_two_slots_ambiguous`] shared with
823    /// the sibling
824    /// `encapsulation_kind_two_slots_is_ambiguous_across_every_pair`
825    /// / `artifact_source_two_slots_is_ambiguous_across_every_pair`
826    /// / `vector_channel_two_slots_is_ambiguous_across_every_pair`
827    /// sites. Subsumes the pre-lift hand-authored two-pair probes
828    /// (`nix + flux`, `nix + guest`) with exhaustive `6 × 5 = 30`
829    /// coverage — every off-diagonal pair on `IntentKind` is pinned.
830    #[test]
831    fn intent_two_slots_is_ambiguous_across_every_pair() {
832        crate::tagged_union::assert_two_slots_ambiguous::<Intent, _>(two_slot_intent);
833    }
834
835    #[test]
836    fn guest_intent_selects_its_variant() {
837        let i = Intent {
838            guest: Some(GuestIntent {
839                spec: serde_json::json!({
840                    "name": "fast-fn",
841                    "kind": { "kind": "wasm", "runtime": "wasmtime",
842                              "wasiPreview": "p2",
843                              "component": { "kind": "flake",
844                                             "value": {"url":"github:x/y","attr":"wasi"} },
845                              "features": { "simd": true } },
846                    "cmdline": []
847                }),
848                state_dir: None,
849                allow_remote_build: Some(true),
850            }),
851            ..Intent::default()
852        };
853        match i.variant().unwrap() {
854            IntentVariant::Guest(g) => {
855                assert_eq!(g.spec["name"], "fast-fn");
856                assert_eq!(g.allow_remote_build, Some(true));
857            }
858            other => panic!("expected Guest, got {other:?}"),
859        }
860    }
861
862    #[test]
863    fn aplicacao_intent_selects_its_variant() {
864        let i = Intent {
865            aplicacao: Some(AplicacaoIntent {
866                chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
867                version: "0.5.5".into(),
868                profile: "all-in-one".into(),
869                values_overlay: serde_json::json!({ "cluster": { "name": "test-01" } }),
870                release_name: None,
871                target_namespace: None,
872                install_timeout: Some("25m".into()),
873            }),
874            ..Intent::default()
875        };
876        match i.variant().unwrap() {
877            IntentVariant::Aplicacao(a) => {
878                assert_eq!(a.profile, "all-in-one");
879                assert_eq!(a.version, "0.5.5");
880                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
881            }
882            other => panic!("expected Aplicacao, got {other:?}"),
883        }
884    }
885
886    /// Structural well-formedness of [`IntentKind`] as a
887    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
888    /// testkit lift that pins all structural invariants (`ALL` is
889    /// non-empty, every variant round-trips through `label ↔
890    /// parse_label`, labels are pairwise distinct, `""` is outside
891    /// the closed set, the `UnknownIntentKind` carrier's Display
892    /// renders the substrate-wide `"unknown intent kind: <input>"`
893    /// shape, `labels()` equals the natural `ALL × label` projection,
894    /// `parse_label_with_hint` composes `parse_label` +
895    /// `suggest_closest` verbatim) at ONE call site. Replaces the
896    /// hand-derived `intent_kind_all_is_unique_and_complete` —
897    /// clause (1)+(3) of the testkit subsume the uniqueness +
898    /// non-emptiness sweep that test pinned independently.
899    #[test]
900    fn intent_kind_is_well_formed_closed_set() {
901        tatara_closed_set::assert_closed_set_well_formed::<IntentKind>();
902    }
903
904    /// The Display impl IS `as_str` — pinning this lets future callers
905    /// reach for either projection without drift. Symmetric to the
906    /// sibling `workload_kind_display_matches_as_str` invariant; if a
907    /// reviewer accidentally re-introduces an inline match in Display,
908    /// this test would fail the moment a variant rename touches one
909    /// site but not the other.
910    ///
911    /// Routes through the substrate primitive
912    /// [`crate::tagged_union::assert_display_matches_label`], which
913    /// composes `<T as ClosedSet>::label` against `T::to_string`
914    /// byte-identically for every `<T: ClosedSet + Display>`
915    /// implementor — the Display-alignment testkit shared with every
916    /// sibling `X_display_matches_as_str` site across the crate.
917    /// Pre-lift the 27 bodies each restated the same
918    /// `for k in K::ALL { assert_eq!(k.to_string(), k.as_str()) }`
919    /// two-line probe at the test surface; post-lift the projection
920    /// lives at ONE substrate primitive and every site binds through
921    /// a single call.
922    #[test]
923    fn intent_kind_display_matches_as_str() {
924        crate::tagged_union::assert_display_matches_label::<IntentKind>();
925    }
926
927    /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
928    /// camelCase serde field name on `Intent`. A future rename of
929    /// any field lands here at one site — and the `Empty` diagnostic
930    /// composed from `INTENT_KIND_LIST` stays coherent with the
931    /// wire format.
932    ///
933    /// Routes through the substrate primitive
934    /// [`crate::tagged_union::assert_single_slot_key_matches_label`],
935    /// which pins the exactly-one-key + name-equality projection
936    /// byte-identically for every `<T: TaggedUnion + Serialize>`
937    /// implementor — the wire-alignment testkit shared with the sibling
938    /// `encapsulation_target_as_str_matches_field_name` /
939    /// `artifact_kind_as_str_matches_field_name` /
940    /// `channel_kind_as_str_matches_field_name` sites. Pre-lift the
941    /// four bodies each restated the same serialize-and-inspect sweep
942    /// at the test surface (three through a weaker YAML-substring
943    /// check; this site alone through the strong JSON-object exactly-
944    /// one form); post-lift the projection lives at ONE substrate
945    /// primitive and every site binds through a single call — the
946    /// three YAML sites simultaneously upgrade to the strong exactly-
947    /// one form.
948    #[test]
949    fn intent_kind_as_str_matches_intent_field_name() {
950        crate::tagged_union::assert_single_slot_key_matches_label::<Intent, _>(single_slot_intent);
951    }
952
953    /// ROUND-TRIP CONTRACT: `IntentKind::select(intent).map(|v|
954    /// v.kind()) == Some(kind)`. The reverse `IntentVariant::kind`
955    /// projection composes the closed set in both directions — a
956    /// regression that misroutes a select arm (e.g. `Self::Nix =>
957    /// intent.flux.as_ref()...`) fails loudly here.
958    ///
959    /// Routes through the substrate primitive
960    /// [`crate::tagged_union::assert_variant_round_trip`], which
961    /// composes [`crate::tagged_union::VariantSelector::select`]
962    /// (forward) with [`crate::tagged_union::VariantKind::variant_kind`]
963    /// (reverse) byte-identically for every `<T: TaggedUnion>`
964    /// implementor — the round-trip testkit shared with the sibling
965    /// `artifact_kind_round_trips_through_variant_kind` /
966    /// `channel_kind_round_trips_through_variant_kind` /
967    /// `encapsulation_target_round_trips_through_variant_target`
968    /// sites. Pre-lift the four bodies each restated the same
969    /// two-arm round-trip probe at the test surface; post-lift the
970    /// projection lives at ONE substrate primitive and every site
971    /// binds through a single call.
972    #[test]
973    fn intent_kind_round_trips_through_variant_kind() {
974        crate::tagged_union::assert_variant_round_trip::<Intent, _>(single_slot_intent);
975    }
976
977    /// PRESENCE-PROBE WIRE CONTRACT: the `intent-<kind>` require-tag
978    /// dispatcher in `tatara-check` (`bin/tatara-check.rs`) parses
979    /// each suffix via `IntentKind::from_str` and dispatches through
980    /// the substrate primitive `Intent::has` (a one-line inherent
981    /// forwarder over [`crate::tagged_union::TaggedUnion::has`]).
982    /// Pre-lift the dispatcher restated five hand-authored
983    /// `spec.intent.<field>.is_some()` arms whose per-field addressing
984    /// drifted from `IntentKind::ALL` (the sixth variant `Guest` had
985    /// no `intent-guest` arm at all); post-lift adding a seventh
986    /// variant to `IntentKind` lands the corresponding `intent-<kind>`
987    /// tag automatically — the sweep here pins that every
988    /// `IntentKind` roundtrips through the `intent-{as_str}` wire
989    /// key, and that `Intent::has(k)` fires exactly on the populated
990    /// slot addressed by `k`.
991    #[test]
992    fn intent_has_dispatches_through_wire_key_across_every_kind() {
993        for populated in IntentKind::ALL {
994            let intent = single_slot_intent(populated);
995            for probed in IntentKind::ALL {
996                let wire_key = format!("intent-{}", probed.as_str());
997                let parsed: IntentKind = wire_key
998                    .strip_prefix("intent-")
999                    .expect("wire key composes as intent-<as_str>")
1000                    .parse()
1001                    .expect("as_str→from_str round trip pinned by DeriveClosedSet");
1002                assert_eq!(parsed, probed);
1003                let expected = probed == populated;
1004                assert_eq!(
1005                    intent.has(probed),
1006                    expected,
1007                    "Intent::has drift — populated={populated:?} probed={probed:?}",
1008                );
1009            }
1010        }
1011    }
1012
1013    /// EMPTY-DIAGNOSTIC CONTRACT: the closed-set kind list embedded
1014    /// in `IntentError::Empty` echoes the canonical join of every
1015    /// `IntentKind::as_str()` projection. A variant added without
1016    /// updating `INTENT_KIND_LIST` (or a renamed variant) shows up
1017    /// here as a mismatch.
1018    ///
1019    /// Routes through the substrate primitive
1020    /// [`crate::tagged_union::assert_kind_list_matches_closed_set`],
1021    /// which composes `<T::Kind as ClosedSet>::labels_joined("/")`
1022    /// against `<T as TaggedUnion>::KIND_LIST` byte-identically for
1023    /// every implementor — the diagnostic-stability testkit shared
1024    /// with the sibling `artifact_error_empty_lists_every_kind_in_canonical_order`
1025    /// / `channel_error_empty_lists_every_kind_in_canonical_order`
1026    /// / `encapsulation_kind_error_empty_lists_every_target_in_canonical_order`
1027    /// sites. Pre-lift the four bodies each restated the same
1028    /// two-argument `assert_eq!(<XxxKind as ClosedSet>::labels_joined("/"),
1029    /// XXX_KIND_LIST)` comparison at the test surface; post-lift
1030    /// the projection lives at ONE substrate primitive and every
1031    /// site binds through a single call.
1032    #[test]
1033    fn intent_error_empty_lists_every_kind_in_canonical_order() {
1034        crate::tagged_union::assert_kind_list_matches_closed_set::<Intent>();
1035    }
1036
1037    /// CANONICAL-BYTES CONTRACT: every populated variant yields the
1038    /// SAME bytes as `serde_json::to_vec` on the inner reference.
1039    /// Pins the lift of the parallel observe-mode match in
1040    /// `tatara-reconciler::render` to this single method.
1041    #[test]
1042    fn intent_variant_canonical_bytes_matches_inner_serialize() {
1043        for kind in IntentKind::ALL {
1044            let i = single_slot_intent(kind);
1045            let v = i.variant().expect("exactly-one variant");
1046            let via_method = v.canonical_bytes();
1047            let expected: Vec<u8> = match &v {
1048                IntentVariant::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
1049                IntentVariant::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
1050                IntentVariant::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
1051                IntentVariant::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
1052                IntentVariant::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
1053                IntentVariant::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
1054            };
1055            assert_eq!(
1056                via_method, expected,
1057                "canonical_bytes mismatch for {kind:?}"
1058            );
1059            assert!(!via_method.is_empty(), "{kind:?} produced empty bytes");
1060        }
1061    }
1062
1063    /// Construct an `Intent` with two slots populated — drives the
1064    /// pairwise `Ambiguous` sweep through the substrate primitive
1065    /// [`crate::tagged_union::assert_two_slots_ambiguous`]. Composes
1066    /// the single-slot constructor on top of itself per-field so ONE
1067    /// source of truth for per-variant inner payloads is preserved.
1068    /// Mirrors `two_slot_source` / `two_slot_channel` / `two_slot_kind`
1069    /// in shape across `ProcessSpec`'s tagged-union axis.
1070    fn two_slot_intent(a: IntentKind, b: IntentKind) -> Intent {
1071        let ia = single_slot_intent(a);
1072        let ib = single_slot_intent(b);
1073        Intent {
1074            nix: ia.nix.or(ib.nix),
1075            flux: ia.flux.or(ib.flux),
1076            lisp: ia.lisp.or(ib.lisp),
1077            container: ia.container.or(ib.container),
1078            aplicacao: ia.aplicacao.or(ib.aplicacao),
1079            guest: ia.guest.or(ib.guest),
1080        }
1081    }
1082
1083    /// Construct an `Intent` with exactly the given kind's slot
1084    /// populated by a minimal valid inner spec. Shared across the
1085    /// closed-set property tests so they each cover every variant
1086    /// without restating the construction table.
1087    fn single_slot_intent(kind: IntentKind) -> Intent {
1088        match kind {
1089            IntentKind::Nix => Intent {
1090                nix: Some(NixIntent {
1091                    flake_ref: "github:a/b".into(),
1092                    attribute: "x".into(),
1093                    system: None,
1094                    attic_cache: None,
1095                    extra_args: vec![],
1096                    delegate_to_nix_build: false,
1097                }),
1098                ..Intent::default()
1099            },
1100            IntentKind::Flux => Intent {
1101                flux: Some(FluxIntent {
1102                    git_repository: "g".into(),
1103                    path: "p".into(),
1104                    git_repository_namespace: None,
1105                    target_namespace: None,
1106                    decrypt_sops: true,
1107                    helm_chart: None,
1108                    helm_values: None,
1109                }),
1110                ..Intent::default()
1111            },
1112            IntentKind::Lisp => Intent {
1113                lisp: Some(LispIntent {
1114                    source: "()".into(),
1115                    reader: "tatara-lisp".into(),
1116                    version: "v1".into(),
1117                    bindings: BTreeMap::new(),
1118                }),
1119                ..Intent::default()
1120            },
1121            IntentKind::Container => Intent {
1122                container: Some(ContainerIntent {
1123                    image: "ghcr.io/x:1".into(),
1124                    replicas: Some(1),
1125                    command: vec![],
1126                    args: vec![],
1127                    env: BTreeMap::new(),
1128                    workload_kind: WorkloadKind::default(),
1129                }),
1130                ..Intent::default()
1131            },
1132            IntentKind::Aplicacao => Intent {
1133                aplicacao: Some(AplicacaoIntent::chart_only("oci://ghcr.io/x", "0.1.0")),
1134                ..Intent::default()
1135            },
1136            IntentKind::Guest => Intent {
1137                guest: Some(GuestIntent {
1138                    spec: serde_json::json!({"name": "guest-1"}),
1139                    state_dir: None,
1140                    allow_remote_build: None,
1141                }),
1142                ..Intent::default()
1143            },
1144        }
1145    }
1146
1147    // ── closed-set algebra for WorkloadKind (ALL × as_str × Display ×
1148    //    FromStr × api_version × is_batch) ─────────────────────────────
1149
1150    /// Structural well-formedness of [`WorkloadKind`] as a
1151    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1152    /// testkit lift that pins all three structural invariants (`ALL`
1153    /// is non-empty, every variant round-trips through `label ↔
1154    /// parse_label`, labels are pairwise distinct, `""` is outside the
1155    /// closed set) at ONE call site. Replaces the hand-derived
1156    /// `workload_kind_all_is_unique_and_complete` +
1157    /// `workload_kind_roundtrip_via_as_str` + the empty-input arm of
1158    /// `unknown_workload_kind_errors`. `FromStr` delegates to
1159    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1160    /// exercises the same code path the reconciler hits when parsing a
1161    /// K8s `kind:`-shaped value back to the typed workload kind.
1162    #[test]
1163    fn workload_kind_is_well_formed_closed_set() {
1164        tatara_closed_set::assert_closed_set_well_formed::<WorkloadKind>();
1165    }
1166
1167    /// CANONICAL-KEY CONTRACT: every variant's `as_str()` matches serde's
1168    /// PascalCase output verbatim. A future variant rename (or an
1169    /// `as_str` arm typo) lands at one site, instead of drifting
1170    /// between the typed surface, the K8s `kind:` manifest field, and
1171    /// the YAML wire format the reconciler / operator both read.
1172    #[test]
1173    fn workload_kind_as_str_matches_serde() {
1174        crate::tagged_union::assert_label_matches_serde_serialization::<WorkloadKind>();
1175    }
1176
1177    /// The Display impl IS `as_str` — pinning this lets future callers
1178    /// reach for either projection without drift. If a reviewer
1179    /// accidentally re-introduces an inline match in Display, this
1180    /// test would fail the moment a variant rename touches one site
1181    /// but not the other.
1182    #[test]
1183    fn workload_kind_display_matches_as_str() {
1184        crate::tagged_union::assert_display_matches_label::<WorkloadKind>();
1185    }
1186
1187    /// `FromStr` rejects strings that aren't in the canonical
1188    /// projection — lowercased / typo / unrelated — and the error
1189    /// echoes the input verbatim so the operator-facing diagnostic
1190    /// carries the offending value, not a normalized form. The
1191    /// empty-input arm is pinned by
1192    /// [`workload_kind_is_well_formed_closed_set`] via the
1193    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1194    /// verbatim-echo contract on the [`UnknownWorkloadKind`]
1195    /// newtype, which the trait's `make_unknown` can't see.
1196    #[test]
1197    fn unknown_workload_kind_errors() {
1198        use std::str::FromStr;
1199        for bad in ["deployment", "JOB", "ReplicaSet", "Pod"] {
1200            let err = WorkloadKind::from_str(bad).unwrap_err();
1201            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1202        }
1203    }
1204
1205    #[test]
1206    fn workload_kind_default_is_deployment() {
1207        assert_eq!(WorkloadKind::default(), WorkloadKind::Deployment);
1208    }
1209
1210    /// TRUTH-TABLE CONTRACT: `api_version` / `is_batch` agree with the
1211    /// documented (kind) -> (apiVersion, is_batch) table for every
1212    /// variant. A new variant in `WorkloadKind` without extending
1213    /// either projection's match is caught by the compiler (closed-set
1214    /// match in each method); adding a variant without extending its
1215    /// truth row is caught here. Also pins the invariant
1216    /// `is_batch <=> api_version == "batch/v1"`, so a future renderer
1217    /// can route on either projection without re-deriving the partition.
1218    #[test]
1219    fn workload_kind_projection_truth_table() {
1220        let table: &[(WorkloadKind, &str, bool)] = &[
1221            // (kind, api_version, is_batch)
1222            (WorkloadKind::Deployment, "apps/v1", false),
1223            (WorkloadKind::StatefulSet, "apps/v1", false),
1224            (WorkloadKind::DaemonSet, "apps/v1", false),
1225            (WorkloadKind::Job, "batch/v1", true),
1226            (WorkloadKind::CronJob, "batch/v1", true),
1227        ];
1228        assert_eq!(table.len(), WorkloadKind::ALL.len());
1229        for (kind, api, batch) in table {
1230            assert_eq!(kind.api_version(), *api, "api_version drift for {kind:?}");
1231            assert_eq!(kind.is_batch(), *batch, "is_batch drift for {kind:?}");
1232            assert_eq!(
1233                kind.is_batch(),
1234                kind.api_version() == "batch/v1",
1235                "is_batch / api_version partition disagrees for {kind:?}"
1236            );
1237        }
1238    }
1239
1240    #[test]
1241    fn aplicacao_plus_flux_is_ambiguous() {
1242        let i = Intent {
1243            aplicacao: Some(AplicacaoIntent::chart_only("x", "1")),
1244            flux: Some(FluxIntent {
1245                git_repository: "g".into(),
1246                path: "p".into(),
1247                git_repository_namespace: None,
1248                target_namespace: None,
1249                decrypt_sops: true,
1250                helm_chart: None,
1251                helm_values: None,
1252            }),
1253            ..Intent::default()
1254        };
1255        assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
1256    }
1257
1258    // ── Helm lifecycle policy — install / upgrade slot substrate ────
1259
1260    fn helm_intent(install_timeout: Option<&str>) -> AplicacaoIntent {
1261        AplicacaoIntent {
1262            install_timeout: install_timeout.map(str::to_string),
1263            ..AplicacaoIntent::chart_only("oci://ghcr.io/pleme-io/charts/lareira-demo-app", "0.5.5")
1264        }
1265    }
1266
1267    /// The workspace-wide default timeout const is pinned to `25m`.
1268    /// A regression that renamed it to any other duration would
1269    /// silently misroute every Helm-driven Process's default retry
1270    /// budget, so pin the byte-exact spelling here rather than at
1271    /// every consumer's own callsite.
1272    #[test]
1273    fn helm_lifecycle_default_timeout_is_pinned_to_25m() {
1274        assert_eq!(HELM_LIFECYCLE_DEFAULT_TIMEOUT, "25m");
1275    }
1276
1277    /// The workspace-wide default retries const is pinned to `3`.
1278    /// Peer to the `_timeout` pin; same rationale.
1279    #[test]
1280    fn helm_lifecycle_default_retries_is_pinned_to_three() {
1281        assert_eq!(HELM_LIFECYCLE_DEFAULT_RETRIES, 3);
1282    }
1283
1284    /// Fallback branch of the primitive: an intent that omitted
1285    /// `install_timeout` picks up the workspace-wide default
1286    /// (`25m` + retries `3`). Pin binds the "no override" shape
1287    /// every render / snapshot / dashboard consumer sees today.
1288    #[test]
1289    fn helm_lifecycle_policy_defaults_when_install_timeout_is_none() {
1290        let policy = helm_intent(None).helm_lifecycle_policy();
1291        assert_eq!(policy.timeout, HELM_LIFECYCLE_DEFAULT_TIMEOUT);
1292        assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1293    }
1294
1295    /// Override branch of the primitive: when the operator populated
1296    /// `install_timeout`, the primitive substitutes that string
1297    /// verbatim (no normalization, no trimming) — the reconciler
1298    /// hands the exact `humantime` shape to Flux, and any parse
1299    /// error surfaces from the chart-controller, not from here.
1300    #[test]
1301    fn helm_lifecycle_policy_uses_install_timeout_when_present() {
1302        for shape in ["10m", "1h30m", "5s", "25m", "0s"] {
1303            let policy = helm_intent(Some(shape)).helm_lifecycle_policy();
1304            assert_eq!(
1305                policy.timeout, shape,
1306                "override shape {shape} not substituted verbatim"
1307            );
1308            // Retries stay at the workspace default regardless of timeout.
1309            assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1310        }
1311    }
1312
1313    /// Coherence axis: the retries slot is invariant across every
1314    /// timeout shape the operator might publish — a regression that
1315    /// coupled the two slots (e.g. "when timeout is short, retry
1316    /// more") surfaces here rather than at every consumer.
1317    #[test]
1318    fn helm_lifecycle_policy_retries_are_invariant_across_timeout_shapes() {
1319        let seen: std::collections::BTreeSet<u8> = [None, Some("1m"), Some("25m"), Some("2h")]
1320            .into_iter()
1321            .map(|t| helm_intent(t).helm_lifecycle_policy().remediation.retries)
1322            .collect();
1323        assert_eq!(
1324            seen.len(),
1325            1,
1326            "retries should be constant across timeout shapes"
1327        );
1328        assert_eq!(
1329            seen.into_iter().next(),
1330            Some(HELM_LIFECYCLE_DEFAULT_RETRIES)
1331        );
1332    }
1333
1334    /// Wire-shape pin: the serde projection matches Flux
1335    /// `HelmRelease.spec.{install,upgrade}` v2 byte-identically —
1336    /// `{"timeout": <string>, "remediation": {"retries": <int>}}`
1337    /// with no extra keys, no field renames, no camelCase surprises.
1338    /// A regression that added a slot to `HelmLifecyclePolicy` or
1339    /// renamed one would fail here rather than as a Flux CR
1340    /// rejection at every deployment.
1341    #[test]
1342    fn helm_lifecycle_policy_serializes_to_flux_hr_v2_install_upgrade_shape() {
1343        let policy = helm_intent(Some("10m")).helm_lifecycle_policy();
1344        let json = serde_json::to_value(&policy).unwrap();
1345        assert_eq!(
1346            json,
1347            serde_json::json!({
1348                "timeout": "10m",
1349                "remediation": { "retries": 3 },
1350            }),
1351        );
1352    }
1353
1354    /// Coherence axis: `HelmLifecyclePolicy::workspace_default()`
1355    /// composes byte-identically to the intent-derived policy of an
1356    /// intent with `install_timeout: None` — the two paths to the
1357    /// substrate default (via the `Aplicacao` intent's own resolver
1358    /// vs the standalone workspace-default constructor) yield the
1359    /// same shape. Binds the "workspace_default IS the fallback"
1360    /// invariant so a future divergence (e.g. workspace_default
1361    /// changes but the intent resolver's inline fallback does not)
1362    /// surfaces here rather than as a silent drift at every render
1363    /// callsite.
1364    #[test]
1365    fn helm_lifecycle_policy_workspace_default_matches_intent_fallback_branch() {
1366        let default_policy = HelmLifecyclePolicy::workspace_default();
1367        let intent_policy = helm_intent(None).helm_lifecycle_policy();
1368        assert_eq!(default_policy, intent_policy);
1369    }
1370
1371    // ── Flux reconcile interval — OCIRepository + HelmRelease shared ─
1372
1373    /// The workspace-wide default Flux reconcile-interval const is
1374    /// pinned to `5m`. A regression that renamed it would silently
1375    /// throttle or hammer every Helm-driven Process's OCIRepository
1376    /// pull cadence AND its HelmRelease reconcile cadence, so pin
1377    /// the byte-exact spelling here rather than at the two render
1378    /// callsites the primitive owns.
1379    #[test]
1380    fn flux_helm_default_interval_is_pinned_to_5m() {
1381        assert_eq!(FLUX_HELM_DEFAULT_INTERVAL, "5m");
1382    }
1383
1384    /// The intent-side composer returns the workspace-wide default
1385    /// verbatim today. A regression that hand-authored some other
1386    /// string here (or that stopped routing through the const)
1387    /// would surface at this pin.
1388    #[test]
1389    fn flux_reconcile_interval_returns_workspace_default() {
1390        assert_eq!(
1391            helm_intent(None).flux_reconcile_interval(),
1392            FLUX_HELM_DEFAULT_INTERVAL,
1393        );
1394    }
1395
1396    /// Coherence axis: the reconcile interval is invariant across
1397    /// every `install_timeout` shape the operator publishes today.
1398    /// Pre-lift the two slots were siblings hand-authored with the
1399    /// same `"5m"` value regardless of any other AplicacaoIntent
1400    /// shape; post-lift the same invariance holds through the
1401    /// composer. A future coupling (e.g. "when timeout is short,
1402    /// reconcile more often") lands at the composer's shape, not
1403    /// silently at any render callsite.
1404    #[test]
1405    fn flux_reconcile_interval_is_invariant_across_install_timeout_shapes() {
1406        let seen: std::collections::BTreeSet<String> =
1407            [None, Some("10m"), Some("1h30m"), Some("25m"), Some("2h")]
1408                .into_iter()
1409                .map(|t| helm_intent(t).flux_reconcile_interval())
1410                .collect();
1411        assert_eq!(
1412            seen.len(),
1413            1,
1414            "reconcile interval should be constant across install_timeout shapes"
1415        );
1416        assert_eq!(
1417            seen.into_iter().next().as_deref(),
1418            Some(FLUX_HELM_DEFAULT_INTERVAL),
1419        );
1420    }
1421
1422    // ─── AplicacaoIntent::chart_only substrate pins ─────────────────
1423    //
1424    // Bind the chart-pointer-only composer at fail-before-pass-after
1425    // granularity so a regression that drifted any of the five
1426    // default-tail slots (profile → non-empty, values_overlay → non-
1427    // `Null`, any of the three `Option<String>` slots → `Some`),
1428    // reshaped the two-argument surface, or swapped the positional
1429    // slot order surfaces HERE rather than as silent fixture skew at
1430    // the 14 downstream consumers.
1431
1432    #[test]
1433    fn chart_only_binds_two_caller_slots_and_defaults_the_other_five() {
1434        // Primary shape asserted end-to-end: the returned value
1435        // carries the caller-supplied `(chart_ref, version)` and the
1436        // K8s-schema-default `("", Null, None, None, None)` tail. A
1437        // regression that swapped the two positional slots would
1438        // land `"1"` in `chart_ref` and `"oci://x"` in `version`;
1439        // the byte-equality pin below catches that.
1440        let a = AplicacaoIntent::chart_only("oci://x", "1");
1441        assert_eq!(a.chart_ref, "oci://x");
1442        assert_eq!(a.version, "1");
1443        assert_eq!(a.profile, "");
1444        assert_eq!(a.values_overlay, serde_json::Value::Null);
1445        assert!(a.release_name.is_none());
1446        assert!(a.target_namespace.is_none());
1447        assert!(a.install_timeout.is_none());
1448    }
1449
1450    #[test]
1451    fn chart_only_matches_hand_authored_pre_lift_struct_literal_shape() {
1452        // Byte-identical parity with the pre-lift 7-slot struct-
1453        // literal every one of the 14 hand-authored sites restated.
1454        // A regression that drifted the composer would surface HERE
1455        // rather than as silent fixture skew at every downstream
1456        // `empty_template` / `sample_intent_for` / `helm_intent`
1457        // consumer. Swept across the three representative
1458        // `(chart_ref, version)` shape families the pre-lift sites
1459        // used (fixture stub `oci://x`/`1`; sample `oci://ghcr.io/x`
1460        // / `0.1.0`; production non-OCI `pleme-io/lareira-demo-app`
1461        // / `0.5.5`).
1462        for (chart_ref, version) in [
1463            ("oci://x", "1"),
1464            ("oci://ghcr.io/x", "0.1.0"),
1465            ("pleme-io/lareira-demo-app", "0.5.5"),
1466            ("x", "1"),
1467        ] {
1468            let composed = AplicacaoIntent::chart_only(chart_ref, version);
1469            let hand_authored = AplicacaoIntent {
1470                chart_ref: chart_ref.into(),
1471                version: version.into(),
1472                profile: String::new(),
1473                values_overlay: serde_json::Value::Null,
1474                release_name: None,
1475                target_namespace: None,
1476                install_timeout: None,
1477            };
1478            assert_eq!(
1479                serde_json::to_value(&composed).unwrap(),
1480                serde_json::to_value(&hand_authored).unwrap(),
1481                "composed and hand-authored must agree for ({chart_ref}, {version})"
1482            );
1483        }
1484    }
1485
1486    #[test]
1487    fn chart_only_accepts_string_and_str_uniformly() {
1488        // The `impl Into<String>` argument form matches both the
1489        // pre-lift `"literal".into()` shape AND callers with a live
1490        // `String` (e.g. a fixture parameter). A regression that
1491        // narrowed the argument type to `&str` or `String` would
1492        // break one of the two shapes; this pin binds both.
1493        let owned_chart = String::from("oci://y");
1494        let owned_version = String::from("2");
1495        let via_string = AplicacaoIntent::chart_only(owned_chart.clone(), owned_version.clone());
1496        let via_str = AplicacaoIntent::chart_only("oci://y", "2");
1497        assert_eq!(
1498            serde_json::to_value(&via_string).unwrap(),
1499            serde_json::to_value(&via_str).unwrap(),
1500        );
1501    }
1502
1503    // ── AplicacaoIntent::{release_name,target_namespace}_or pins ───
1504    //
1505    // Bind the (override, fallback) fallback-composer pair at
1506    // fail-before-pass-after granularity so a regression that flipped
1507    // the branch order (fallback wins when override is Some), dropped
1508    // the `.clone()` on the override, dropped the lazy branch on the
1509    // fallback, or swapped the two field slots surfaces HERE rather
1510    // than as silent drift at the two production consumers
1511    // (`tatara-reconciler::render::render_aplicacao` and
1512    // `tatara-process::matrix::EnvMatrixSpec::breathe_bands`).
1513
1514    #[test]
1515    fn release_name_or_returns_override_when_set() {
1516        let mut a = AplicacaoIntent::chart_only("oci://x", "1");
1517        a.release_name = Some("operator-picked".into());
1518        assert_eq!(a.release_name_or("fallback-pid"), "operator-picked");
1519    }
1520
1521    #[test]
1522    fn release_name_or_returns_fallback_when_unset() {
1523        let a = AplicacaoIntent::chart_only("oci://x", "1");
1524        assert!(a.release_name.is_none());
1525        assert_eq!(a.release_name_or("fallback-pid"), "fallback-pid");
1526    }
1527
1528    #[test]
1529    fn target_namespace_or_returns_override_when_set() {
1530        let mut a = AplicacaoIntent::chart_only("oci://x", "1");
1531        a.target_namespace = Some("operator-picked".into());
1532        assert_eq!(a.target_namespace_or("fallback-ns"), "operator-picked");
1533    }
1534
1535    #[test]
1536    fn target_namespace_or_returns_fallback_when_unset() {
1537        let a = AplicacaoIntent::chart_only("oci://x", "1");
1538        assert!(a.target_namespace.is_none());
1539        assert_eq!(a.target_namespace_or("fallback-ns"), "fallback-ns");
1540    }
1541
1542    #[test]
1543    fn release_name_and_target_namespace_or_are_independent_across_all_four_shapes() {
1544        // Coherence axis: the two fallback slots are independent —
1545        // any of the four (release_name, target_namespace) ∈
1546        // {None, Some} shapes projects the expected pair with no
1547        // cross-slot bleed. A regression that keyed one field off
1548        // the other's `Option` state would surface HERE.
1549        for (rn, tn) in [
1550            (None, None),
1551            (Some("r".to_string()), None),
1552            (None, Some("t".to_string())),
1553            (Some("r".to_string()), Some("t".to_string())),
1554        ] {
1555            let mut a = AplicacaoIntent::chart_only("oci://x", "1");
1556            a.release_name = rn.clone();
1557            a.target_namespace = tn.clone();
1558            let got_rn = a.release_name_or("rn-fb");
1559            let got_tn = a.target_namespace_or("tn-fb");
1560            let want_rn = rn.clone().unwrap_or_else(|| "rn-fb".into());
1561            let want_tn = tn.clone().unwrap_or_else(|| "tn-fb".into());
1562            assert_eq!(got_rn, want_rn, "release_name_or for ({rn:?}, {tn:?})");
1563            assert_eq!(got_tn, want_tn, "target_namespace_or for ({rn:?}, {tn:?})");
1564        }
1565    }
1566
1567    #[test]
1568    fn release_name_or_matches_hand_authored_pre_lift_option_clone_unwrap_or_else_shape() {
1569        // Byte-identical parity with the two pre-lift call shapes.
1570        // A regression that drifted the composer would surface HERE
1571        // rather than as silent skew at either production site.
1572        for override_ in [None, Some("op".to_string())] {
1573            let mut a = AplicacaoIntent::chart_only("oci://x", "1");
1574            a.release_name = override_.clone();
1575            let via_primitive = a.release_name_or("fb");
1576            let hand_authored = a.release_name.clone().unwrap_or_else(|| "fb".into());
1577            assert_eq!(via_primitive, hand_authored);
1578        }
1579        for override_ in [None, Some("op".to_string())] {
1580            let mut a = AplicacaoIntent::chart_only("oci://x", "1");
1581            a.target_namespace = override_.clone();
1582            let via_primitive = a.target_namespace_or("fb");
1583            let hand_authored = a.target_namespace.clone().unwrap_or_else(|| "fb".into());
1584            assert_eq!(via_primitive, hand_authored);
1585        }
1586    }
1587
1588    #[test]
1589    fn chart_only_composes_downstream_through_helm_lifecycle_policy_default_branch() {
1590        // Cross-primitive coherence pin: an `AplicacaoIntent` built
1591        // through `chart_only` has `install_timeout = None` and
1592        // therefore rides the workspace-default branch of
1593        // `helm_lifecycle_policy`. A regression that flipped
1594        // `install_timeout` to `Some(_)` at the composer would
1595        // silently un-default every downstream Helm policy; this
1596        // pin binds the default-branch composition end-to-end.
1597        let a = AplicacaoIntent::chart_only("oci://x", "1");
1598        let policy = a.helm_lifecycle_policy();
1599        assert_eq!(policy.timeout, HELM_LIFECYCLE_DEFAULT_TIMEOUT);
1600        assert_eq!(policy.remediation.retries, HELM_LIFECYCLE_DEFAULT_RETRIES);
1601    }
1602}