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}