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
6/// Boundary specification — preconditions gate Running,
7/// postconditions gate Running → Attested.
8#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "camelCase")]
10pub struct Boundary {
11    #[serde(default)]
12    pub preconditions: Vec<Condition>,
13    #[serde(default)]
14    pub postconditions: Vec<Condition>,
15    /// Max time before VERIFY fails — parsed as a `go`-style duration.
16    /// Empty = controller default (15m).
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub timeout: Option<String>,
19}
20
21/// A single boundary predicate.
22#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct Condition {
25    pub kind: ConditionKind,
26    /// Kind-specific payload (free-form JSON).
27    #[serde(default)]
28    #[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
29    pub params: serde_json::Value,
30}
31
32#[derive(
33    Clone,
34    Copy,
35    Debug,
36    PartialEq,
37    Eq,
38    Hash,
39    Serialize,
40    Deserialize,
41    JsonSchema,
42    tatara_closed_set::DeriveClosedSet,
43)]
44#[serde(rename_all = "PascalCase")]
45#[closed_set(via = "as_str", display, generate_unknown)]
46pub enum ConditionKind {
47    /// Another Process must be in a given phase.
48    /// `params`: `{ "processRef": "...", "namespace": "...", "phase": "Attested" }`
49    ProcessPhase,
50    /// FluxCD `Kustomization.status.conditions[type=Ready]` must be `True`.
51    /// `params`: `{ "name": "...", "namespace": "flux-system" }`
52    KustomizationHealthy,
53    /// FluxCD `HelmRelease.status.conditions[type=Ready]` must be `True`.
54    /// `params`: `{ "name": "...", "namespace": "..." }`
55    HelmReleaseReleased,
56    /// Prometheus query — truthy scalar required.
57    /// `params`: `{ "query": "..." }`
58    PromQL,
59    /// CEL expression over a scoped object set.
60    /// `params`: `{ "expression": "..." }`
61    Cel,
62    /// Nix evaluation equality check.
63    /// `params`: `{ "flakeRef": "...", "attribute": "...", "expect": "..." }`
64    NixEval,
65    /// A Kubernetes Job must complete successfully and its emitted BLAKE3
66    /// receipt must verify.
67    /// `params`: `{ "name": "...", "namespace": "...", "expectReceipt": true }`
68    JobAttested,
69    /// Closed-loop authentication probe — the canonical postcondition for
70    /// any system that can produce credentials for its own client under
71    /// test. The probe Job (rendered by the VERIFY handler) fetches a
72    /// fresh secret from `issuer` (a Service inside the same namespace),
73    /// presents it to `consumer` (another Service in the same namespace),
74    /// and verifies that `consumer` authenticated successfully against
75    /// `jwk_source` (the issuer's published JWK endpoint).
76    ///
77    /// The Job emits a three-pillar BLAKE3 receipt that the reconciler
78    /// chains into `status.attestation`. This turns "the gateway↔SaaS
79    /// loop holds" from an assertion into a theorem provable for every
80    /// ephemeral run.
81    ///
82    /// `params`:
83    /// ```json
84    /// {
85    ///   "issuer":   { "service": "akeyless-saas-akeyless-gator",
86    ///                 "port": 8080,
87    ///                 "secretPath": "/v2/get-secret-value" },
88    ///   "consumer": { "service": "akeyless-saas-akeyless-gateway",
89    ///                 "port": 8000,
90    ///                 "authPath": "/api/v3/auth" },
91    ///   "jwkSource":{ "service": "akeyless-saas-akeyless-gator",
92    ///                 "port": 8080,
93    ///                 "path": "/.well-known/jwks.json" },
94    ///   "probeImage": "ghcr.io/pleme-io/closed-loop-probe:0.1.0",
95    ///   "timeoutSeconds": 120
96    /// }
97    /// ```
98    ClosedLoopAuth,
99}
100
101impl ConditionKind {
102    /// The closed set of boundary-condition kinds the reconciler honors.
103    /// Single source of truth that drives the `as_str` / Display /
104    /// `FromStr` triad on this enum and the `stub_message` lift of the
105    /// "not yet implemented" arms the reconciler used to hand-roll three
106    /// times. Adding a 9th variant lands at one `ALL` entry + one `as_str`
107    /// arm + one `stub_message` arm — exhaustively checked by the
108    /// compiler (the array literal forces arity).
109    ///
110    /// Sibling closed-set lifts: [`crate::phase::ProcessPhase::ALL`],
111    /// [`crate::signal::ProcessSignal::ALL`], [`crate::intent::IntentKind::ALL`],
112    /// [`crate::lifetime::LifetimeKind::ALL`].
113    pub const ALL: [Self; 8] = [
114        Self::ProcessPhase,
115        Self::KustomizationHealthy,
116        Self::HelmReleaseReleased,
117        Self::PromQL,
118        Self::Cel,
119        Self::NixEval,
120        Self::JobAttested,
121        Self::ClosedLoopAuth,
122    ];
123
124    /// Canonical PascalCase wire-format projection — matches the serde
125    /// `rename_all = "PascalCase"` output verbatim. Used by Display
126    /// (single source of truth), by `FromStr` to identify the variant
127    /// from its annotation / status-field representation, and by
128    /// operator-facing diagnostics that need the kind name without
129    /// re-serializing the enum through serde_json. Pinned by
130    /// `condition_kind_as_str_matches_serde`.
131    pub const fn as_str(self) -> &'static str {
132        match self {
133            Self::ProcessPhase => "ProcessPhase",
134            Self::KustomizationHealthy => "KustomizationHealthy",
135            Self::HelmReleaseReleased => "HelmReleaseReleased",
136            Self::PromQL => "PromQL",
137            Self::Cel => "Cel",
138            Self::NixEval => "NixEval",
139            Self::JobAttested => "JobAttested",
140            Self::ClosedLoopAuth => "ClosedLoopAuth",
141        }
142    }
143
144    /// The operator-facing "evaluator not yet implemented" message for
145    /// stub kinds — `Some` iff this kind has no live evaluator wired in
146    /// `tatara-reconciler::boundary`. ONE site owns the per-kind stub
147    /// string; the reconciler's dispatch reaches for this projection
148    /// instead of hand-rolling three parallel `Unknown(...)` strings.
149    ///
150    /// A future variant added as a live evaluator returns `None`; a
151    /// future variant added as a stub returns `Some("<kind> evaluator
152    /// not yet implemented")` — both reachable through one match
153    /// instead of three identical-shape arms drifting in parallel.
154    pub const fn stub_message(self) -> Option<&'static str> {
155        match self {
156            Self::PromQL => Some("PromQL evaluator not yet implemented"),
157            Self::Cel => Some("CEL evaluator not yet implemented"),
158            Self::NixEval => Some("NixEval evaluator not yet implemented"),
159            Self::ProcessPhase
160            | Self::KustomizationHealthy
161            | Self::HelmReleaseReleased
162            | Self::JobAttested
163            | Self::ClosedLoopAuth => None,
164        }
165    }
166
167    /// True iff this kind has no live evaluator (its [`Self::stub_message`]
168    /// is `Some`). Pairs with the reconciler's `evaluate` dispatch — a
169    /// stub kind unconditionally yields `Satisfaction::Unknown`.
170    pub const fn is_stub(self) -> bool {
171        self.stub_message().is_some()
172    }
173}
174
175// `impl fmt::Display for ConditionKind` + `impl FromStr for
176// ConditionKind` + `impl tatara_lisp::ClosedSet for ConditionKind` +
177// `pub struct UnknownConditionKind(pub String)` are generated by
178// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
179// "as_str", display, generate_unknown)]` on the enum declaration above.
180// The auto-derived label `"condition kind"` matches the prior hand-
181// rolled `#[error("unknown condition kind: {0}")]` verbatim. The
182// inherent `as_str` projection stays load-bearing — the PascalCase
183// wire-format that matches the serde rename + the CRD `enum:` listing
184// verbatim (notably preserving `PromQL`'s consecutive caps that heck
185// would have lowercased) — while the trait method `label` gives
186// generic consumers a STABLE name across the 36+ workspace-wide
187// closed-set implementors.
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    #[test]
195    fn serde_process_phase_condition() {
196        let c = Condition {
197            kind: ConditionKind::ProcessPhase,
198            params: json!({ "processRef": "akeyless-injection", "phase": "Attested" }),
199        };
200        let yaml = serde_yaml::to_string(&c).unwrap();
201        assert!(yaml.contains("kind: ProcessPhase"));
202        assert!(yaml.contains("processRef: akeyless-injection"));
203    }
204
205    #[test]
206    fn serde_closed_loop_auth_condition() {
207        let c = Condition {
208            kind: ConditionKind::ClosedLoopAuth,
209            params: json!({
210                "issuer":   { "service": "akeyless-saas-akeyless-gator", "port": 8080 },
211                "consumer": { "service": "akeyless-saas-akeyless-gateway", "port": 8000 },
212                "probeImage": "ghcr.io/pleme-io/closed-loop-probe:0.1.0",
213            }),
214        };
215        let yaml = serde_yaml::to_string(&c).unwrap();
216        assert!(yaml.contains("kind: ClosedLoopAuth"));
217        assert!(yaml.contains("probeImage: ghcr.io/pleme-io/closed-loop-probe:0.1.0"));
218        let back: Condition = serde_yaml::from_str(&yaml).unwrap();
219        assert_eq!(back.kind, ConditionKind::ClosedLoopAuth);
220    }
221
222    #[test]
223    fn serde_job_attested_condition() {
224        let c = Condition {
225            kind: ConditionKind::JobAttested,
226            params: json!({ "name": "seed-job", "namespace": "akeyless-test" }),
227        };
228        let yaml = serde_yaml::to_string(&c).unwrap();
229        assert!(yaml.contains("kind: JobAttested"));
230    }
231
232    // ── closed-set algebra contracts (ALL × as_str × FromStr × stub_message) ─
233
234    /// Structural well-formedness of [`ConditionKind`] as a
235    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
236    /// testkit lift that pins all three structural invariants (`ALL`
237    /// is non-empty, every variant round-trips through `label ↔
238    /// parse_label`, labels are pairwise distinct, `""` is outside the
239    /// closed set) at ONE call site. Replaces the hand-derived
240    /// `condition_kind_all_is_unique_and_complete` +
241    /// `condition_kind_roundtrip_via_as_str` + the empty-input arm of
242    /// `unknown_condition_kind_errors`. `FromStr` delegates to
243    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
244    /// exercises the same code path the reconciler hits when parsing a
245    /// CRD `enum:`-validated value back to the typed kind.
246    #[test]
247    fn condition_kind_is_well_formed_closed_set() {
248        tatara_closed_set::assert_closed_set_well_formed::<ConditionKind>();
249    }
250
251    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
252    /// output verbatim for every variant. A future variant rename
253    /// (or an `as_str` arm typo) lands here at one site. The probe
254    /// confirmed `PromQL` survives `rename_all = "PascalCase"` as
255    /// `"PromQL"` (heck preserves consecutive caps in the leading
256    /// word), so this contract is the operator-facing pin.
257    #[test]
258    fn condition_kind_as_str_matches_serde() {
259        for kind in ConditionKind::ALL {
260            let serialized = serde_json::to_string(&kind)
261                .expect("ConditionKind serializes")
262                .trim_matches('"')
263                .to_string();
264            assert_eq!(
265                kind.as_str(),
266                serialized,
267                "as_str() must match serde output for {kind:?}",
268            );
269        }
270    }
271
272    /// The Display impl IS `as_str` — pinning this lets future
273    /// callers reach for either projection without drift. If a
274    /// reviewer accidentally re-introduces an inline match in
275    /// Display, this fails the moment a variant rename touches one
276    /// site but not the other.
277    #[test]
278    fn condition_kind_display_matches_as_str() {
279        for kind in ConditionKind::ALL {
280            assert_eq!(kind.to_string(), kind.as_str());
281        }
282    }
283
284    /// `FromStr` rejects strings that aren't in the canonical
285    /// projection — lowercased / typo / unrelated — and the error
286    /// echoes the input verbatim so the operator-facing diagnostic
287    /// carries the offending value, not a normalized form. The
288    /// empty-input arm is pinned by
289    /// [`condition_kind_is_well_formed_closed_set`] via the
290    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
291    /// verbatim-echo contract on the [`UnknownConditionKind`]
292    /// newtype, which the trait's `make_unknown` can't see.
293    #[test]
294    fn unknown_condition_kind_errors() {
295        use std::str::FromStr;
296        for bad in ["processPhase", "PROMQL", "Promql", "Bogus"] {
297            let err = ConditionKind::from_str(bad).unwrap_err();
298            assert_eq!(err.0, bad, "error payload should echo input verbatim");
299        }
300    }
301
302    /// STUB CONTRACT: the three placeholder evaluators
303    /// (PromQL / Cel / NixEval) are exactly the set whose
304    /// `stub_message` is `Some`. The five live evaluators return
305    /// `None`. A future variant promoted from stub → live must drop
306    /// its `stub_message` arm; a new stub must add one. Both
307    /// transitions land at this test by sweeping ALL.
308    #[test]
309    fn condition_kind_stub_set_matches_stubs() {
310        use ConditionKind::*;
311        for kind in ConditionKind::ALL {
312            let expected_is_stub = matches!(kind, PromQL | Cel | NixEval);
313            assert_eq!(
314                kind.is_stub(),
315                expected_is_stub,
316                "is_stub disagreed for {kind:?}",
317            );
318            assert_eq!(
319                kind.stub_message().is_some(),
320                expected_is_stub,
321                "stub_message disagreed for {kind:?}",
322            );
323        }
324    }
325
326    /// Pin the exact stub strings so a rename of the operator-facing
327    /// "not yet implemented" message lands at one site (here) instead
328    /// of three parallel inline strings in the reconciler.
329    #[test]
330    fn condition_kind_stub_messages_are_pinned() {
331        assert_eq!(
332            ConditionKind::PromQL.stub_message(),
333            Some("PromQL evaluator not yet implemented"),
334        );
335        assert_eq!(
336            ConditionKind::Cel.stub_message(),
337            Some("CEL evaluator not yet implemented"),
338        );
339        assert_eq!(
340            ConditionKind::NixEval.stub_message(),
341            Some("NixEval evaluator not yet implemented"),
342        );
343    }
344}