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}