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