Skip to main content

tatara_process/
boundary.rs

1//! Boundary conditions — predicates that gate phase transitions.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::flux_resource::FluxResource;
7
8/// Boundary specification — preconditions gate Running,
9/// postconditions gate Running → Attested.
10#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct Boundary {
13    #[serde(default)]
14    pub preconditions: Vec<Condition>,
15    #[serde(default)]
16    pub postconditions: Vec<Condition>,
17    /// Max time before VERIFY fails — parsed as a `go`-style duration.
18    /// Empty = controller default (15m).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub timeout: Option<String>,
21}
22
23/// A single boundary predicate.
24#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct Condition {
27    pub kind: ConditionKind,
28    /// Kind-specific payload (free-form JSON).
29    #[serde(default)]
30    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
31    pub params: serde_json::Value,
32}
33
34#[derive(
35    Clone,
36    Copy,
37    Debug,
38    PartialEq,
39    Eq,
40    Hash,
41    Serialize,
42    Deserialize,
43    JsonSchema,
44    tatara_closed_set::DeriveClosedSet,
45)]
46#[serde(rename_all = "PascalCase")]
47#[closed_set(via = "as_str", display, generate_unknown)]
48pub enum ConditionKind {
49    /// Another Process must be in a given phase.
50    /// `params`: `{ "processRef": "...", "namespace": "...", "phase": "Attested" }`
51    ProcessPhase,
52    /// FluxCD `Kustomization.status.conditions[type=Ready]` must be `True`.
53    /// `params`: `{ "name": "...", "namespace": "flux-system" }`
54    KustomizationHealthy,
55    /// FluxCD `HelmRelease.status.conditions[type=Ready]` must be `True`.
56    /// `params`: `{ "name": "...", "namespace": "..." }`
57    HelmReleaseReleased,
58    /// Prometheus query — truthy scalar required.
59    /// `params`: `{ "query": "..." }`
60    PromQL,
61    /// CEL expression over a scoped object set.
62    /// `params`: `{ "expression": "..." }`
63    Cel,
64    /// Nix evaluation equality check.
65    /// `params`: `{ "flakeRef": "...", "attribute": "...", "expect": "..." }`
66    NixEval,
67    /// A Kubernetes Job must complete successfully and its emitted BLAKE3
68    /// receipt must verify.
69    /// `params`: `{ "name": "...", "namespace": "...", "expectReceipt": true }`
70    JobAttested,
71    /// Closed-loop authentication probe — the canonical postcondition for
72    /// any system that can produce credentials for its own client under
73    /// test. The probe Job (rendered by the VERIFY handler) fetches a
74    /// fresh secret from `issuer` (a Service inside the same namespace),
75    /// presents it to `consumer` (another Service in the same namespace),
76    /// and verifies that `consumer` authenticated successfully against
77    /// `jwk_source` (the issuer's published JWK endpoint).
78    ///
79    /// The Job emits a three-pillar BLAKE3 receipt that the reconciler
80    /// chains into `status.attestation`. This turns "the gateway↔SaaS
81    /// loop holds" from an assertion into a theorem provable for every
82    /// ephemeral run.
83    ///
84    /// `params`:
85    /// ```json
86    /// {
87    ///   "issuer":   { "service": "demo-app-issuer",
88    ///                 "port": 8080,
89    ///                 "secretPath": "/v2/get-secret-value" },
90    ///   "consumer": { "service": "demo-app-gateway",
91    ///                 "port": 8000,
92    ///                 "authPath": "/api/v3/auth" },
93    ///   "jwkSource":{ "service": "demo-app-issuer",
94    ///                 "port": 8080,
95    ///                 "path": "/.well-known/jwks.json" },
96    ///   "probeImage": "ghcr.io/pleme-io/closed-loop-probe:0.1.0",
97    ///   "timeoutSeconds": 120
98    /// }
99    /// ```
100    ClosedLoopAuth,
101}
102
103impl ConditionKind {
104    /// The closed set of boundary-condition kinds the reconciler honors.
105    /// Single source of truth that drives the `as_str` / Display /
106    /// `FromStr` triad on this enum and the `stub_message` lift of the
107    /// "not yet implemented" arms the reconciler used to hand-roll three
108    /// times. Adding a 9th variant lands at one `ALL` entry + one `as_str`
109    /// arm + one `stub_message` arm — exhaustively checked by the
110    /// compiler (the array literal forces arity).
111    ///
112    /// Sibling closed-set lifts: [`crate::phase::ProcessPhase::ALL`],
113    /// [`crate::signal::ProcessSignal::ALL`], [`crate::intent::IntentKind::ALL`],
114    /// [`crate::lifetime::LifetimeKind::ALL`].
115    pub const ALL: [Self; 8] = [
116        Self::ProcessPhase,
117        Self::KustomizationHealthy,
118        Self::HelmReleaseReleased,
119        Self::PromQL,
120        Self::Cel,
121        Self::NixEval,
122        Self::JobAttested,
123        Self::ClosedLoopAuth,
124    ];
125
126    /// Canonical PascalCase wire-format projection — matches the serde
127    /// `rename_all = "PascalCase"` output verbatim. Used by Display
128    /// (single source of truth), by `FromStr` to identify the variant
129    /// from its annotation / status-field representation, and by
130    /// operator-facing diagnostics that need the kind name without
131    /// re-serializing the enum through serde_json. Pinned by
132    /// `condition_kind_as_str_matches_serde`.
133    pub const fn as_str(self) -> &'static str {
134        match self {
135            Self::ProcessPhase => "ProcessPhase",
136            Self::KustomizationHealthy => "KustomizationHealthy",
137            Self::HelmReleaseReleased => "HelmReleaseReleased",
138            Self::PromQL => "PromQL",
139            Self::Cel => "Cel",
140            Self::NixEval => "NixEval",
141            Self::JobAttested => "JobAttested",
142            Self::ClosedLoopAuth => "ClosedLoopAuth",
143        }
144    }
145
146    /// The operator-facing "evaluator not yet implemented" message for
147    /// stub kinds — `Some` iff this kind has no live evaluator wired in
148    /// `tatara-reconciler::boundary`. ONE site owns the per-kind stub
149    /// string; the reconciler's dispatch reaches for this projection
150    /// instead of hand-rolling three parallel `Unknown(...)` strings.
151    ///
152    /// A future variant added as a live evaluator returns `None`; a
153    /// future variant added as a stub returns `Some("<kind> evaluator
154    /// not yet implemented")` — both reachable through one match
155    /// instead of three identical-shape arms drifting in parallel.
156    pub const fn stub_message(self) -> Option<&'static str> {
157        match self {
158            Self::PromQL => Some("PromQL evaluator not yet implemented"),
159            Self::Cel => Some("CEL evaluator not yet implemented"),
160            Self::NixEval => Some("NixEval evaluator not yet implemented"),
161            Self::ProcessPhase
162            | Self::KustomizationHealthy
163            | Self::HelmReleaseReleased
164            | Self::JobAttested
165            | Self::ClosedLoopAuth => None,
166        }
167    }
168
169    /// True iff this kind has no live evaluator (its [`Self::stub_message`]
170    /// is `Some`). Pairs with the reconciler's `evaluate` dispatch — a
171    /// stub kind unconditionally yields `Satisfaction::Unknown`.
172    pub const fn is_stub(self) -> bool {
173        self.stub_message().is_some()
174    }
175
176    /// The [`FluxResource`] variant this condition kind fetches from
177    /// the K8s API server, or `None` for non-Flux-fetching kinds — the
178    /// typed projection owning the (ConditionKind → FluxResource)
179    /// association every reconciler `evaluate` dispatch arm and every
180    /// future coherence check binds through.
181    ///
182    /// Pre-lift the association was open-coded at TWO adjacent
183    /// `evaluate` arms in `tatara-reconciler::boundary::evaluate` past
184    /// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold — each arm
185    /// hand-authored a `(FluxResource::X.api_version(),
186    /// FluxResource::X.kind())` pair as the two `&str` slots the
187    /// pre-lift `evaluate_flux_ready(api_version: &str, kind: &str)`
188    /// signature required. Post-lift the mapping lives at ONE typed
189    /// projection here, the callee accepts a typed
190    /// [`FluxResource`] slot (invalid `(apiVersion, kind)` pairings
191    /// like Kustomization's apiVersion paired with HelmRelease's kind
192    /// become unrepresentable), and the two `evaluate` arms collapse
193    /// onto ONE `KustomizationHealthy | HelmReleaseReleased` OR-arm
194    /// that reads the FluxResource variant from `.flux_resource()`.
195    ///
196    /// A future ConditionKind that fetches a fourth Flux resource
197    /// variant (a hypothetical `BucketSynced` kind against a Flux
198    /// `Bucket` source) lands as ONE new arm here + ONE new variant
199    /// on [`FluxResource`] + ONE OR-pattern extension at the
200    /// reconciler dispatch — no hand-authored `(apiVersion, kind)`
201    /// pair at the callsite, no widening of the callee's signature.
202    ///
203    /// The three current non-Flux-fetching arms return `None`:
204    /// - `ProcessPhase` fetches a tatara `Process` (through its own
205    ///   [`crate::api_version`] + [`crate::PROCESS_KIND`] pair, not
206    ///   a Flux `(apiVersion, kind)`).
207    /// - `JobAttested` / `ClosedLoopAuth` fetch a `batch/v1::Job` +
208    ///   an optional receipt `v1::ConfigMap`, both K8s built-ins
209    ///   (not Flux resources).
210    /// - `PromQL` / `Cel` / `NixEval` are stub evaluators
211    ///   ([`Self::is_stub`]) — no cluster fetch at all.
212    ///
213    /// Theory anchor: THEORY.md §II.1 invariant 5 (composition
214    /// preserves proofs — the (ConditionKind → FluxResource)
215    /// association lives at ONE typed algebra projection here, not
216    /// at every reconciler dispatch arm).
217    pub const fn flux_resource(self) -> Option<FluxResource> {
218        match self {
219            Self::KustomizationHealthy => Some(FluxResource::Kustomization),
220            Self::HelmReleaseReleased => Some(FluxResource::HelmRelease),
221            Self::ProcessPhase
222            | Self::PromQL
223            | Self::Cel
224            | Self::NixEval
225            | Self::JobAttested
226            | Self::ClosedLoopAuth => None,
227        }
228    }
229}
230
231// `impl fmt::Display for ConditionKind` + `impl FromStr for
232// ConditionKind` + `impl tatara_lisp::ClosedSet for ConditionKind` +
233// `pub struct UnknownConditionKind(pub String)` are generated by
234// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
235// "as_str", display, generate_unknown)]` on the enum declaration above.
236// The auto-derived label `"condition kind"` matches the prior hand-
237// rolled `#[error("unknown condition kind: {0}")]` verbatim. The
238// inherent `as_str` projection stays load-bearing — the PascalCase
239// wire-format that matches the serde rename + the CRD `enum:` listing
240// verbatim (notably preserving `PromQL`'s consecutive caps that heck
241// would have lowercased) — while the trait method `label` gives
242// generic consumers a STABLE name across the 36+ workspace-wide
243// closed-set implementors.
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use serde_json::json;
249
250    #[test]
251    fn serde_process_phase_condition() {
252        let c = Condition {
253            kind: ConditionKind::ProcessPhase,
254            params: json!({ "processRef": "secret-injection", "phase": "Attested" }),
255        };
256        let yaml = serde_yaml::to_string(&c).unwrap();
257        assert!(yaml.contains("kind: ProcessPhase"));
258        assert!(yaml.contains("processRef: secret-injection"));
259    }
260
261    #[test]
262    fn serde_closed_loop_auth_condition() {
263        let c = Condition {
264            kind: ConditionKind::ClosedLoopAuth,
265            params: json!({
266                "issuer":   { "service": "demo-app-issuer", "port": 8080 },
267                "consumer": { "service": "demo-app-gateway", "port": 8000 },
268                "probeImage": "ghcr.io/pleme-io/closed-loop-probe:0.1.0",
269            }),
270        };
271        let yaml = serde_yaml::to_string(&c).unwrap();
272        assert!(yaml.contains("kind: ClosedLoopAuth"));
273        assert!(yaml.contains("probeImage: ghcr.io/pleme-io/closed-loop-probe:0.1.0"));
274        let back: Condition = serde_yaml::from_str(&yaml).unwrap();
275        assert_eq!(back.kind, ConditionKind::ClosedLoopAuth);
276    }
277
278    #[test]
279    fn serde_job_attested_condition() {
280        let c = Condition {
281            kind: ConditionKind::JobAttested,
282            params: json!({ "name": "seed-job", "namespace": "demo-test" }),
283        };
284        let yaml = serde_yaml::to_string(&c).unwrap();
285        assert!(yaml.contains("kind: JobAttested"));
286    }
287
288    // ── closed-set algebra contracts (ALL × as_str × FromStr × stub_message) ─
289
290    /// Structural well-formedness of [`ConditionKind`] as a
291    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
292    /// testkit lift that pins all three structural invariants (`ALL`
293    /// is non-empty, every variant round-trips through `label ↔
294    /// parse_label`, labels are pairwise distinct, `""` is outside the
295    /// closed set) at ONE call site. Replaces the hand-derived
296    /// `condition_kind_all_is_unique_and_complete` +
297    /// `condition_kind_roundtrip_via_as_str` + the empty-input arm of
298    /// `unknown_condition_kind_errors`. `FromStr` delegates to
299    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
300    /// exercises the same code path the reconciler hits when parsing a
301    /// CRD `enum:`-validated value back to the typed kind.
302    #[test]
303    fn condition_kind_is_well_formed_closed_set() {
304        tatara_closed_set::assert_closed_set_well_formed::<ConditionKind>();
305    }
306
307    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
308    /// output verbatim for every variant. A future variant rename
309    /// (or an `as_str` arm typo) lands here at one site. The probe
310    /// confirmed `PromQL` survives `rename_all = "PascalCase"` as
311    /// `"PromQL"` (heck preserves consecutive caps in the leading
312    /// word), so this contract is the operator-facing pin.
313    #[test]
314    fn condition_kind_as_str_matches_serde() {
315        crate::tagged_union::assert_label_matches_serde_serialization::<ConditionKind>();
316    }
317
318    /// The Display impl IS `as_str` — pinning this lets future
319    /// callers reach for either projection without drift. If a
320    /// reviewer accidentally re-introduces an inline match in
321    /// Display, this fails the moment a variant rename touches one
322    /// site but not the other.
323    #[test]
324    fn condition_kind_display_matches_as_str() {
325        crate::tagged_union::assert_display_matches_label::<ConditionKind>();
326    }
327
328    /// `FromStr` rejects strings that aren't in the canonical
329    /// projection — lowercased / typo / unrelated — and the error
330    /// echoes the input verbatim so the operator-facing diagnostic
331    /// carries the offending value, not a normalized form. The
332    /// empty-input arm is pinned by
333    /// [`condition_kind_is_well_formed_closed_set`] via the
334    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
335    /// verbatim-echo contract on the [`UnknownConditionKind`]
336    /// newtype, which the trait's `make_unknown` can't see.
337    #[test]
338    fn unknown_condition_kind_errors() {
339        use std::str::FromStr;
340        for bad in ["processPhase", "PROMQL", "Promql", "Bogus"] {
341            let err = ConditionKind::from_str(bad).unwrap_err();
342            assert_eq!(err.0, bad, "error payload should echo input verbatim");
343        }
344    }
345
346    /// STUB CONTRACT: the three placeholder evaluators
347    /// (PromQL / Cel / NixEval) are exactly the set whose
348    /// `stub_message` is `Some`. The five live evaluators return
349    /// `None`. A future variant promoted from stub → live must drop
350    /// its `stub_message` arm; a new stub must add one. Both
351    /// transitions land at this test by sweeping ALL.
352    #[test]
353    fn condition_kind_stub_set_matches_stubs() {
354        use ConditionKind::*;
355        for kind in ConditionKind::ALL {
356            let expected_is_stub = matches!(kind, PromQL | Cel | NixEval);
357            assert_eq!(
358                kind.is_stub(),
359                expected_is_stub,
360                "is_stub disagreed for {kind:?}",
361            );
362            assert_eq!(
363                kind.stub_message().is_some(),
364                expected_is_stub,
365                "stub_message disagreed for {kind:?}",
366            );
367        }
368    }
369
370    /// Pin the exact stub strings so a rename of the operator-facing
371    /// "not yet implemented" message lands at one site (here) instead
372    /// of three parallel inline strings in the reconciler.
373    #[test]
374    fn condition_kind_stub_messages_are_pinned() {
375        assert_eq!(
376            ConditionKind::PromQL.stub_message(),
377            Some("PromQL evaluator not yet implemented"),
378        );
379        assert_eq!(
380            ConditionKind::Cel.stub_message(),
381            Some("CEL evaluator not yet implemented"),
382        );
383        assert_eq!(
384            ConditionKind::NixEval.stub_message(),
385            Some("NixEval evaluator not yet implemented"),
386        );
387    }
388
389    // ── (ConditionKind → FluxResource) typed projection contracts ────
390
391    /// The two Flux-fetching kinds project to their canonical
392    /// [`FluxResource`] variants. A future ConditionKind rename or
393    /// FluxResource variant rename that skewed the projection at ONE
394    /// arm surfaces here.
395    #[test]
396    fn kustomization_healthy_projects_to_flux_resource_kustomization() {
397        assert_eq!(
398            ConditionKind::KustomizationHealthy.flux_resource(),
399            Some(FluxResource::Kustomization),
400        );
401    }
402
403    #[test]
404    fn helm_release_released_projects_to_flux_resource_helm_release() {
405        assert_eq!(
406            ConditionKind::HelmReleaseReleased.flux_resource(),
407            Some(FluxResource::HelmRelease),
408        );
409    }
410
411    /// The six non-Flux-fetching kinds project to `None`. Sweeps
412    /// `ConditionKind::ALL` filtering by `flux_resource().is_none()`
413    /// so a new variant added without a `flux_resource` arm surfaces
414    /// at rustc's non-exhaustive-match gate BEFORE this test even
415    /// runs; a new variant added with a hand-coded `Some(...)` arm
416    /// that shouldn't fetch Flux surfaces here.
417    #[test]
418    fn non_flux_fetching_kinds_project_to_none() {
419        use ConditionKind::*;
420        let non_flux: Vec<_> = ConditionKind::ALL
421            .iter()
422            .copied()
423            .filter(|k| k.flux_resource().is_none())
424            .collect();
425        assert_eq!(
426            non_flux,
427            vec![
428                ProcessPhase,
429                PromQL,
430                Cel,
431                NixEval,
432                JobAttested,
433                ClosedLoopAuth
434            ],
435        );
436    }
437
438    /// Every variant of [`ConditionKind`] whose `flux_resource()` is
439    /// `Some` uniquely names its FluxResource variant (no two
440    /// ConditionKind arms may fetch the SAME FluxResource — that
441    /// would signal a redundant closed-set entry). Peers the
442    /// `every_variants_api_version_and_kind_are_distinct_across_the_closed_set`
443    /// pin on the sibling [`FluxResource`] closed set.
444    #[test]
445    fn flux_resource_projection_is_injective_on_the_some_arms() {
446        let mut seen = std::collections::HashSet::new();
447        for k in ConditionKind::ALL {
448            if let Some(fr) = k.flux_resource() {
449                assert!(
450                    seen.insert(fr),
451                    "duplicate FluxResource projection at {k:?}: {fr:?}",
452                );
453            }
454        }
455    }
456
457    /// `flux_resource` is `const fn` — the projection is reachable
458    /// at compile time. A regression that dropped the `const`
459    /// qualifier would fail-loudly here rather than as a wrong-slot
460    /// runtime dispatch at every consumer callsite.
461    #[test]
462    fn flux_resource_projection_is_const_fn_reachable() {
463        const K: Option<FluxResource> = ConditionKind::KustomizationHealthy.flux_resource();
464        const H: Option<FluxResource> = ConditionKind::HelmReleaseReleased.flux_resource();
465        const P: Option<FluxResource> = ConditionKind::ProcessPhase.flux_resource();
466        assert_eq!(K, Some(FluxResource::Kustomization));
467        assert_eq!(H, Some(FluxResource::HelmRelease));
468        assert_eq!(P, None);
469    }
470}