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