tatara_process/compliance.rs
1//! Compliance bindings — CRD-facing with bridges to `tatara_core::compliance_binding`.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use tatara_core::domain::compliance_binding as core;
7
8use crate::phase::ProcessPhase;
9
10/// Compliance section of `ProcessSpec`.
11#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
12#[serde(rename_all = "camelCase")]
13pub struct ComplianceSpec {
14 /// Canonical baseline (e.g., `fedramp-moderate`, `cis-k8s-v1.8`, `soc2`, `pci-dss`).
15 /// Semantically the `meet` of all `bindings`.
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub baseline: Option<String>,
18 /// Individual control bindings.
19 #[serde(default)]
20 pub bindings: Vec<ComplianceBinding>,
21 /// Allow the reconciler to invoke remediation hooks on violations.
22 #[serde(default)]
23 pub auto_remediate: bool,
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "camelCase")]
28pub struct ComplianceBinding {
29 /// Framework name: `nist-800-53`, `cis-k8s-v1.8`, `fedramp-moderate`, `soc2`, `pci-dss`.
30 pub framework: String,
31 /// Control id within the framework (e.g., `SC-7`, `5.1.1`).
32 pub control_id: String,
33 /// When the binding is verified.
34 #[serde(default)]
35 pub phase: VerificationPhase,
36 /// Optional human description.
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39}
40
41/// When a ComplianceBinding is evaluated.
42#[derive(
43 Clone,
44 Copy,
45 Debug,
46 PartialEq,
47 Eq,
48 Hash,
49 Serialize,
50 Deserialize,
51 JsonSchema,
52 Default,
53 tatara_closed_set::DeriveClosedSet,
54)]
55#[serde(rename_all = "PascalCase")]
56#[closed_set(via = "as_str", generate_unknown, display)]
57pub enum VerificationPhase {
58 /// Before Execing — fails reconciliation if violated.
59 PlanTime,
60 /// During VERIFY — gates Running → Attested.
61 #[default]
62 AtBoundary,
63 /// After Attested — continuous audit, emits events on violation.
64 PostConvergence,
65}
66
67impl VerificationPhase {
68 /// The closed set of verification phases — single source of truth that
69 /// drives the `as_str` / Display / `FromStr` triad and the typed
70 /// `gates_phase` projection over [`ProcessPhase`]. Adding a fourth
71 /// variant lands at one `ALL` entry + one `as_str` arm + one
72 /// `gates_phase` arm — exhaustively checked by the compiler (the
73 /// `[Self; 3]` array literal forces the arity).
74 ///
75 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
76 /// [`crate::signal::SighupStrategy::ALL`],
77 /// [`crate::spec::MustReachPhase::ALL`],
78 /// [`crate::intent::WorkloadKind::ALL`],
79 /// [`crate::export::ReportFormat::ALL`],
80 /// [`crate::encapsulates::EncapsulationMode::ALL`],
81 /// [`crate::export::ExportTrigger::ALL`],
82 /// [`crate::lifetime::TeardownPolicy::ALL`],
83 /// [`crate::boundary::ConditionKind::ALL`],
84 /// [`crate::lifetime::LifetimeKind::ALL`],
85 /// [`crate::intent::IntentKind::ALL`],
86 /// [`crate::phase::ProcessPhase::ALL`],
87 /// [`crate::signal::ProcessSignal::ALL`].
88 pub const ALL: [Self; 3] = [Self::PlanTime, Self::AtBoundary, Self::PostConvergence];
89
90 /// Canonical PascalCase wire-format projection — matches the serde
91 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
92 /// enumeration the reconciler stamps on the
93 /// `processes.tatara.pleme.io` schema. Pinned by
94 /// `verification_phase_as_str_matches_serde` so a variant rename
95 /// can't drift between the typed surface, the CRD enum, and the
96 /// YAML wire format at one site.
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::PlanTime => "PlanTime",
100 Self::AtBoundary => "AtBoundary",
101 Self::PostConvergence => "PostConvergence",
102 }
103 }
104
105 /// Typed `const fn` projection onto the [`ProcessPhase`] gate the
106 /// binding's verification blocks when it fails. Each variant maps
107 /// to the earliest phase whose entry the binding can prevent:
108 ///
109 /// - `PlanTime` → `Some(Execing)` — the RENDER phase is what
110 /// PlanTime gates ("Before Execing — fails reconciliation if
111 /// violated"); a violated PlanTime control prevents the
112 /// `Forking → Execing` transition.
113 /// - `AtBoundary` → `Some(Attested)` — the VERIFY phase ("gates
114 /// Running → Attested"); a violated AtBoundary control prevents
115 /// the `Running → Attested` transition.
116 /// - `PostConvergence` → `None` — the binding is non-blocking
117 /// ("After Attested — continuous audit, emits events on
118 /// violation"); it never gates a transition.
119 ///
120 /// Single source of truth for the future reconciler control-plane
121 /// compliance evaluator's "which transition would a failing
122 /// binding block?" decision; pinned by
123 /// `verification_phase_gates_phase_truth_table`. Closed-set match
124 /// (not `matches!`) so adding a fourth variant triggers the
125 /// compiler's exhaustiveness check at this site rather than
126 /// silently defaulting to either group.
127 pub const fn gates_phase(self) -> Option<ProcessPhase> {
128 match self {
129 Self::PlanTime => Some(ProcessPhase::Execing),
130 Self::AtBoundary => Some(ProcessPhase::Attested),
131 Self::PostConvergence => None,
132 }
133 }
134}
135
136// `impl FromStr for VerificationPhase` +
137// `impl tatara_lisp::ClosedSet for VerificationPhase` +
138// `impl fmt::Display for VerificationPhase` +
139// `pub struct UnknownVerificationPhase(pub String)` are all
140// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` +
141// `#[closed_set(via = "as_str", generate_unknown, display)]` on the
142// enum declaration above. `label` delegates to the inherent
143// `VerificationPhase::as_str` (which matches the serde
144// `rename_all = "PascalCase"` projection AND the CRD `enum:`
145// enumeration verbatim — pinned by
146// `verification_phase_as_str_matches_serde`). The auto-derived
147// carrier label "verification phase" matches the prior hand-rolled
148// `#[error("unknown verification phase: {0}")]` annotation
149// byte-for-byte. Symmetric to every other `#[derive(DeriveClosedSet)]`
150// implementor across the crate.
151
152impl From<VerificationPhase> for core::VerificationPhase {
153 fn from(v: VerificationPhase) -> Self {
154 match v {
155 VerificationPhase::PlanTime => Self::PlanTime,
156 VerificationPhase::AtBoundary => Self::AtBoundary,
157 VerificationPhase::PostConvergence => Self::PostConvergence,
158 }
159 }
160}
161
162impl From<core::VerificationPhase> for VerificationPhase {
163 fn from(v: core::VerificationPhase) -> Self {
164 use core::VerificationPhase as C;
165 match v {
166 C::PlanTime => Self::PlanTime,
167 C::AtBoundary => Self::AtBoundary,
168 C::PostConvergence => Self::PostConvergence,
169 }
170 }
171}
172
173impl ComplianceBinding {
174 pub fn to_core(&self) -> core::ComplianceControl {
175 core::ComplianceControl {
176 framework: self.framework.clone(),
177 control_id: self.control_id.clone(),
178 description: self.description.clone().unwrap_or_default(),
179 }
180 }
181}
182
183/// Slice-level `(VerificationPhase, presence)` probe on any
184/// `&[ComplianceBinding]` — the ONE substrate primitive that owns the
185/// `.iter().any(|b| b.phase == K)` walk shape for the compliance-
186/// binding vector. Callers compose the answer they want on top:
187/// `spec.compliance.bindings.has_verification_phase(kind)` for the
188/// point-domain `verification-phase-<kind>` require-tag family, a
189/// coherence check that verifies "every `PlanTime` binding predicates
190/// on a framework the fleet publishes", an editor completion listing
191/// which [`VerificationPhase`] gates the operator authored — every
192/// future consumer reaches this ONE primitive through
193/// `slice.has_verification_phase(k)` instead of restating the
194/// `.iter().any` closure body.
195///
196/// # Third instance in the slice-level presence-probe algebra
197///
198/// Same axis, same shape, third instance in the workspace-wide
199/// slice-level closed-set-driven presence-probe algebra alongside
200/// [`crate::boundary::ConditionSliceExt::has_kind`] on `&[Condition]`
201/// and [`crate::spec::DependsOnSliceExt::has_must_reach`] on
202/// `&[DependsOn]`. All three live one composition boundary below the
203/// tagged-union-parent probes ([`crate::intent::Intent::has`],
204/// [`crate::lifetime::Lifetime::has`],
205/// [`crate::boundary::Boundary::has_condition_kind`]) at the
206/// (`&self`, `K`) → `bool` signature; a future normalization at the
207/// slice-level probe shape (widening the return to
208/// `Option<&ComplianceBinding>` for deeper diagnostics, adding a
209/// debug-build assertion on redundant duplicate `(framework,
210/// control_id)` pairs at the same phase, switching to a linear scan
211/// that also counts matches) lands at ONE site here and every
212/// downstream `slice.has_verification_phase(K)` callsite picks it up
213/// mechanically.
214///
215/// # Compounding
216///
217/// The `verification-phase-<kind>` require-tag prefix family in
218/// `tatara-reconciler::bin::tatara-check` composes this primitive with
219/// the closed-set `FromStr` autoderived on [`VerificationPhase`]
220/// through the `strip_and_classify_prefixed_kind` substrate to publish
221/// a sixth closed-set-driven prefix family byte-for-byte symmetrical
222/// with `intent-<kind>` / `lifetime-<kind>` / `condition-<kind>` /
223/// `must-reach-<kind>` / `sighup-<kind>`. Coexists with the coarse
224/// `compliance` fixed tag (which answers "does this spec carry ANY
225/// compliance binding") — the two tags publish distinct answers.
226/// A future fourth [`VerificationPhase`] variant added to `ALL` (a
227/// hypothetical `Continuous` checkpoint) reaches every downstream
228/// through the SAME closed-set walk with no per-caller edit.
229///
230/// Theory anchor: THEORY.md §II.1 invariant 5 — composition preserves
231/// proofs; the per-slice `phase` walk lives at ONE substrate site so
232/// every downstream (require-tag classifier, coherence check, editor
233/// completion) binds through the SAME shape rather than restating the
234/// `.iter().any(|b| b.phase == K)` closure body at each callsite.
235/// THEORY.md §VI.1 — generation over composition; a future
236/// [`VerificationPhase`] variant lands at ONE `ALL` entry + ONE
237/// `as_str` arm on the closed set and the presence probe picks it up
238/// mechanically without further per-consumer edits.
239pub trait ComplianceBindingSliceExt {
240 /// True iff at least one [`ComplianceBinding`] in this slice
241 /// verifies at the given [`VerificationPhase`]. The single-slice
242 /// presence probe every consumer of the `(&[ComplianceBinding],
243 /// VerificationPhase) -> bool` shape composes against.
244 fn has_verification_phase(&self, kind: VerificationPhase) -> bool;
245}
246
247impl ComplianceBindingSliceExt for [ComplianceBinding] {
248 fn has_verification_phase(&self, kind: VerificationPhase) -> bool {
249 self.iter().any(|b| b.phase == kind)
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn default_phase_is_at_boundary() {
259 assert_eq!(VerificationPhase::default(), VerificationPhase::AtBoundary);
260 }
261
262 #[test]
263 fn binding_roundtrip_to_core() {
264 let b = ComplianceBinding {
265 framework: "nist-800-53".into(),
266 control_id: "SC-7".into(),
267 phase: VerificationPhase::AtBoundary,
268 description: Some("boundary protection".into()),
269 };
270 let c = b.to_core();
271 assert_eq!(c.framework, "nist-800-53");
272 assert_eq!(c.control_id, "SC-7");
273 }
274
275 // ── closed-set algebra contracts (ALL × as_str × FromStr × gates_phase) ──
276
277 /// Structural well-formedness of [`VerificationPhase`] as a
278 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
279 /// testkit lift that pins all three structural invariants
280 /// (`ALL` is non-empty, every variant round-trips through
281 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
282 /// outside the closed set) at ONE call site. Replaces the
283 /// hand-derived `verification_phase_all_is_unique_and_complete` +
284 /// `verification_phase_roundtrip_via_as_str` + the empty-input
285 /// arm of the per-implementor unknown-error test. `FromStr`
286 /// delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
287 /// so this helper exercises the same code path the reconciler
288 /// hits when parsing a CRD `enum:`-validated value back to the
289 /// typed phase.
290 #[test]
291 fn verification_phase_is_well_formed_closed_set() {
292 tatara_closed_set::assert_closed_set_well_formed::<VerificationPhase>();
293 }
294
295 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
296 /// output verbatim for every variant. A future variant rename
297 /// (or an `as_str` arm typo) lands here at one site, instead of
298 /// drifting between the typed surface and the YAML wire format
299 /// the reconciler / operator both read. NOT lifted into the
300 /// `ClosedSet` testkit — `serde_json` is NOT a `tatara-lisp`
301 /// dependency, and per-implementor serde-shape choices (PascalCase
302 /// for CRD enums, snake_case for camelCase carriers, lowercase
303 /// for Lisp keyword projections) make a generic helper a
304 /// category error.
305 #[test]
306 fn verification_phase_as_str_matches_serde() {
307 crate::tagged_union::assert_label_matches_serde_serialization::<VerificationPhase>();
308 }
309
310 /// The Display impl IS `as_str` — pinning this lets future callers
311 /// reach for either projection without drift.
312 #[test]
313 fn verification_phase_display_matches_as_str() {
314 crate::tagged_union::assert_display_matches_label::<VerificationPhase>();
315 }
316
317 /// `FromStr` rejects domain-specific non-canonical inputs and
318 /// the error echoes the input VERBATIM so the operator-facing
319 /// diagnostic carries the offending value. Kept per-implementor
320 /// because the verbatim-payload contract is a property of the
321 /// per-enum `Unknown<X>(pub String)` newtype, not of the trait's
322 /// structural surface. (The empty-input arm is now lifted into
323 /// `verification_phase_is_well_formed_closed_set`; the
324 /// case-drifted / hyphenated / extinct-variant arms stay here as
325 /// they're representative non-canonical inputs the operator
326 /// might supply.)
327 #[test]
328 fn unknown_verification_phase_errors() {
329 use std::str::FromStr;
330 for bad in [
331 "plantime",
332 "ATBOUNDARY",
333 "Plan-Time",
334 "post_convergence",
335 "Continuous",
336 ] {
337 let err = VerificationPhase::from_str(bad).unwrap_err();
338 assert_eq!(err.0, bad, "error payload should echo input verbatim");
339 }
340 }
341
342 /// TRUTH-TABLE CONTRACT: `gates_phase` agrees with the documented
343 /// per-variant codomain (the phase whose entry a violated binding
344 /// blocks, or `None` for non-blocking continuous-audit phases).
345 #[test]
346 fn verification_phase_gates_phase_truth_table() {
347 assert_eq!(
348 VerificationPhase::PlanTime.gates_phase(),
349 Some(ProcessPhase::Execing)
350 );
351 assert_eq!(
352 VerificationPhase::AtBoundary.gates_phase(),
353 Some(ProcessPhase::Attested)
354 );
355 assert_eq!(VerificationPhase::PostConvergence.gates_phase(), None);
356 }
357
358 /// SUBSET CONTRACT: every `Some(target)` `gates_phase` projects to
359 /// is a phase reachable as the destination of some legal
360 /// `ProcessPhase::can_transition_to` edge. A future variant that
361 /// projected to a `ProcessPhase` no transition leads into would
362 /// FAIL here, forcing the author to either pick a real gate phase
363 /// or extend `can_transition_to` deliberately. The reachability
364 /// check is the cross-enum coherence proof — the typed-phase
365 /// state machine and the verification-phase gate algebra agree on
366 /// which phases are gateable.
367 #[test]
368 fn verification_phase_gates_phase_projects_to_reachable_phases() {
369 for vp in VerificationPhase::ALL {
370 if let Some(target) = vp.gates_phase() {
371 let reachable = ProcessPhase::ALL
372 .into_iter()
373 .any(|src| src != target && src.can_transition_to(target));
374 assert!(
375 reachable,
376 "{vp:?}.gates_phase() = Some({target:?}) but no legal transition lands on {target:?}",
377 );
378 }
379 }
380 }
381
382 /// INJECTIVITY CONTRACT: distinct `Some` variants of `gates_phase`
383 /// project to distinct `ProcessPhase`s. Pairing this with the
384 /// subset contract above forces a future variant to land on a
385 /// fresh gateable phase (or project to `None` and be a deliberate
386 /// non-blocking auditor).
387 #[test]
388 fn verification_phase_gates_phase_is_injective() {
389 let projections: Vec<ProcessPhase> = VerificationPhase::ALL
390 .into_iter()
391 .filter_map(VerificationPhase::gates_phase)
392 .collect();
393 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
394 assert_eq!(
395 projections.len(),
396 unique.len(),
397 "gates_phase projection is not injective: {projections:?}",
398 );
399 }
400
401 // ── ComplianceBindingSliceExt::has_verification_phase substrate pins ──
402 //
403 // Fail-before-pass-after granularity: `ComplianceBindingSliceExt`
404 // did not exist before this commit — the `(&[ComplianceBinding],
405 // VerificationPhase) -> bool` walk shape was not spelled anywhere in
406 // the workspace. The lift opens the third instance in the slice-
407 // level closed-set-driven presence-probe algebra (peer of
408 // `ConditionSliceExt::has_kind` on `&[Condition]` and
409 // `DependsOnSliceExt::has_must_reach` on `&[DependsOn]`), enabling
410 // the sixth `verification-phase-<kind>` require-tag prefix family in
411 // `tatara-reconciler::bin::tatara-check` to compose against ONE
412 // substrate site rather than restating the `.iter().any(|b| b.phase
413 // == K)` closure body inline at the classifier.
414
415 fn binding_at(phase: VerificationPhase) -> ComplianceBinding {
416 ComplianceBinding {
417 framework: "nist-800-53".into(),
418 control_id: "SC-7".into(),
419 phase,
420 description: None,
421 }
422 }
423
424 /// EMPTY-SLICE pin — an empty `&[ComplianceBinding]` returns
425 /// `false` for EVERY [`VerificationPhase`]. Sweep
426 /// [`VerificationPhase::ALL`] so a new variant added without a
427 /// matching arm in the primitive surfaces at rustc's exhaustiveness
428 /// gate on the ALL literal (arity forced by `[Self; 3]`) rather than
429 /// as a silent false-positive at every downstream callsite composing
430 /// this primitive.
431 #[test]
432 fn compliance_binding_slice_has_verification_phase_returns_false_on_empty_slice_for_every_kind()
433 {
434 let empty: &[ComplianceBinding] = &[];
435 for kind in VerificationPhase::ALL {
436 assert!(
437 !empty.has_verification_phase(kind),
438 "empty slice must return false for {kind:?}",
439 );
440 }
441 }
442
443 /// PER-VARIANT pin — a single-element slice returns `true` for
444 /// exactly the phase it carries, `false` for every other variant.
445 /// Sweep the [`VerificationPhase::ALL`] × ALL cross so a regression
446 /// that (a) hard-coded the arm to a single kind (silently returning
447 /// true for every populated slice regardless of query kind), or
448 /// (b) matched on [`ComplianceBinding::framework`] instead of
449 /// [`ComplianceBinding::phase`] fails HERE at the substrate
450 /// primitive.
451 #[test]
452 fn compliance_binding_slice_has_verification_phase_reads_phase_field_per_variant() {
453 for populated in VerificationPhase::ALL {
454 let slice = [binding_at(populated)];
455 for query in VerificationPhase::ALL {
456 let expected = query == populated;
457 assert_eq!(
458 slice.has_verification_phase(query),
459 expected,
460 "populated={populated:?}: query {query:?} drifted",
461 );
462 }
463 }
464 }
465
466 /// MULTI-ENTRY pin — a slice with multiple entries returns `true`
467 /// for every phase that appears at any position (existential
468 /// quantifier over the slice), `false` for phases that appear at
469 /// no position. Locks the `any` semantics so a regression that
470 /// collapsed to a `first`-only probe (`slice.first().map_or(false,
471 /// |b| b.phase == kind)`) fails here even though the single-element
472 /// per-variant pin above passes.
473 #[test]
474 fn compliance_binding_slice_has_verification_phase_scans_beyond_the_first_position() {
475 let slice = [
476 binding_at(VerificationPhase::PlanTime),
477 binding_at(VerificationPhase::PostConvergence),
478 ];
479 for present in [
480 VerificationPhase::PlanTime,
481 VerificationPhase::PostConvergence,
482 ] {
483 assert!(
484 slice.has_verification_phase(present),
485 "phase at any position must resolve true: {present:?}",
486 );
487 }
488 assert!(
489 !slice.has_verification_phase(VerificationPhase::AtBoundary),
490 "phase absent from the slice must resolve false: AtBoundary",
491 );
492 }
493}