tatara_process/spec.rs
1//! `ProcessSpec` sub-structures — IdentitySpec, DependsOn, SignalPolicy.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::phase::ProcessPhase;
7use crate::signal::SighupStrategy;
8
9/// Identity configuration for a Process.
10#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct IdentitySpec {
13 /// Parent PID path (None for init/PID 1).
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub parent: Option<String>,
16 /// Human name override — if set, used verbatim instead of the content hash.
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub name_override: Option<String>,
19}
20
21/// Dependency edge — constrains this Process to wait for another to reach a phase.
22#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct DependsOn {
25 /// Target Process `metadata.name`.
26 pub name: String,
27 /// Target Process namespace. Defaults to this Process's namespace.
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub namespace: Option<String>,
30 /// Minimum phase the target must reach before we proceed past Forking.
31 #[serde(default)]
32 pub must_reach: MustReachPhase,
33}
34
35/// Allowed "must reach" phases for a dependency — restricted to the
36/// useful gating checkpoints `Running` (alive + boundary preconditions
37/// held) and `Attested` (alive + boundary postconditions held + three-
38/// pillar attestation written). Authoring a `DependsOn { must_reach:
39/// Forking }` is meaningless; the closed set rules it out at the type
40/// level.
41///
42/// Sibling closed-set lifts on the same `ProcessSpec` axis:
43/// [`crate::lifetime::LifetimeKind::ALL`],
44/// [`crate::lifetime::TeardownPolicy::ALL`],
45/// [`crate::boundary::ConditionKind::ALL`],
46/// [`crate::phase::ProcessPhase::ALL`],
47/// [`crate::signal::ProcessSignal::ALL`].
48#[derive(
49 Clone,
50 Copy,
51 Debug,
52 PartialEq,
53 Eq,
54 Hash,
55 Serialize,
56 Deserialize,
57 JsonSchema,
58 Default,
59 tatara_closed_set::DeriveClosedSet,
60)]
61#[serde(rename_all = "PascalCase")]
62#[closed_set(via = "as_str", display, generate_unknown = "must-reach phase")]
63pub enum MustReachPhase {
64 Running,
65 #[default]
66 Attested,
67}
68
69impl MustReachPhase {
70 /// The closed set of must-reach phases — single source of truth that
71 /// drives the `as_str` / Display / `FromStr` triad and the typed
72 /// `as_process_phase` projection. Adding a third variant (e.g. a
73 /// future `Released` checkpoint that waits for the target Process to
74 /// have exited cleanly) lands at one `ALL` entry, one `as_str` arm,
75 /// and one `as_process_phase` arm — exhaustively checked by the
76 /// compiler (the `[Self; 2]` array literal forces the arity).
77 pub const ALL: [Self; 2] = [Self::Running, Self::Attested];
78
79 /// Canonical PascalCase wire-format projection — matches the serde
80 /// `rename_all = "PascalCase"` output verbatim AND the canonical
81 /// `ProcessPhase::as_str()` projection on the phase this variant
82 /// gates against. Used by Display (single source of truth), by
83 /// `FromStr` to identify the variant from its annotation / status-
84 /// field representation, and by operator-facing diagnostic strings
85 /// (`tatara-reconciler::boundary::check_depends_on` stamps the
86 /// required phase via `Display` rather than reaching for `{:?}`
87 /// Debug formatting). Pinned by `must_reach_phase_as_str_matches_serde`
88 /// AND by `must_reach_phase_as_str_matches_process_phase_as_str` so
89 /// a rename on either side surfaces at one site.
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 Self::Running => "Running",
93 Self::Attested => "Attested",
94 }
95 }
96
97 /// Typed projection into the canonical `ProcessPhase` this variant
98 /// gates against. The `From<MustReachPhase> for ProcessPhase` impl
99 /// delegates here so callers reach for whichever surface fits (the
100 /// `From` for `into()` flows, this `const fn` for const contexts).
101 /// Pinned by `must_reach_phase_from_delegates_to_as_process_phase`.
102 pub const fn as_process_phase(self) -> ProcessPhase {
103 match self {
104 Self::Running => ProcessPhase::Running,
105 Self::Attested => ProcessPhase::Attested,
106 }
107 }
108}
109
110// `impl FromStr for MustReachPhase` +
111// `impl tatara_lisp::ClosedSet for MustReachPhase` +
112// `impl fmt::Display for MustReachPhase` +
113// `pub struct UnknownMustReachPhase(pub String)` are all generated
114// by `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
115// "as_str", display, generate_unknown = "must-reach phase")]` on
116// the enum declaration above. `label` delegates to the inherent
117// `MustReachPhase::as_str` — the PascalCase wire-vocabulary
118// projection stays load-bearing (matches the serde rename AND the
119// canonical `ProcessPhase::as_str` of the phase this variant gates
120// against, pinned by
121// `must_reach_phase_as_str_matches_process_phase_as_str`), while
122// generic `T: ClosedSet` consumers reach the STABLE workspace-wide
123// name (`label`). The explicit `generate_unknown = "must-reach
124// phase"` label carries the hyphenated wording that the
125// auto-derived `pascal_to_spaced_lowercase("MustReachPhase")` →
126// "must reach phase" projection cannot produce — the prior
127// hand-rolled `#[error("unknown must-reach phase: {0}")]`
128// annotation kept the hyphen, and the explicit attribute preserves
129// it through the lift. Symmetric to every other
130// `#[derive(DeriveClosedSet)]` implementor across the crate.
131
132impl From<MustReachPhase> for ProcessPhase {
133 fn from(v: MustReachPhase) -> Self {
134 v.as_process_phase()
135 }
136}
137
138/// Signal policy — how the Process responds to signals.
139#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
140#[serde(rename_all = "camelCase")]
141pub struct SignalPolicy {
142 /// Grace before escalating SIGTERM → SIGKILL.
143 #[serde(default = "default_sigterm_grace")]
144 pub sigterm_grace_seconds: u32,
145 /// Permit force-reap via SIGKILL (default: allow).
146 #[serde(default = "default_true")]
147 pub sigkill_force: bool,
148 /// How SIGHUP is handled.
149 #[serde(default)]
150 pub sighup_strategy: SighupStrategy,
151 /// Start suspended — requires SIGCONT to transition past Forking.
152 #[serde(default)]
153 pub start_suspended: bool,
154}
155
156impl Default for SignalPolicy {
157 fn default() -> Self {
158 Self {
159 sigterm_grace_seconds: default_sigterm_grace(),
160 sigkill_force: true,
161 sighup_strategy: SighupStrategy::default(),
162 start_suspended: false,
163 }
164 }
165}
166
167fn default_sigterm_grace() -> u32 {
168 480
169}
170fn default_true() -> bool {
171 true
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn must_reach_default_is_attested() {
180 assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
181 }
182
183 #[test]
184 fn signal_policy_defaults() {
185 let p = SignalPolicy::default();
186 assert_eq!(p.sigterm_grace_seconds, 480);
187 assert!(p.sigkill_force);
188 assert!(!p.start_suspended);
189 }
190
191 // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
192 // as_process_phase) ──────────────────────────────────────────────
193
194 /// Structural well-formedness of [`MustReachPhase`] as a
195 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
196 /// testkit lift that pins all three structural invariants (`ALL`
197 /// is non-empty, every variant round-trips through `label ↔
198 /// parse_label`, labels are pairwise distinct, `""` is outside the
199 /// closed set) at ONE call site. Replaces the hand-derived
200 /// `must_reach_phase_all_is_unique_and_complete` +
201 /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
202 /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
203 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
204 /// exercises the same code path the reconciler hits when parsing
205 /// a CRD `enum:`-validated value back to the typed checkpoint.
206 #[test]
207 fn must_reach_phase_is_well_formed_closed_set() {
208 tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
209 }
210
211 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
212 /// output verbatim for every variant. A future variant rename (or
213 /// an `as_str` arm typo) lands here at one site.
214 #[test]
215 fn must_reach_phase_as_str_matches_serde() {
216 for kind in MustReachPhase::ALL {
217 let serialized = serde_json::to_string(&kind)
218 .expect("MustReachPhase serializes")
219 .trim_matches('"')
220 .to_string();
221 assert_eq!(
222 kind.as_str(),
223 serialized,
224 "as_str() must match serde output for {kind:?}",
225 );
226 }
227 }
228
229 /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
230 /// matches the canonical `ProcessPhase::as_str()` of the phase it
231 /// projects to. The two enums share the PascalCase wire format
232 /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
233 /// safe gating checkpoints; a rename on either side (a phase
234 /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
235 /// surfaces here at one site, not buried in a reconciler diagnostic
236 /// that quietly drifted away from the typed-phase surface.
237 #[test]
238 fn must_reach_phase_as_str_matches_process_phase_as_str() {
239 for kind in MustReachPhase::ALL {
240 assert_eq!(
241 kind.as_str(),
242 kind.as_process_phase().as_str(),
243 "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
244 );
245 }
246 }
247
248 /// The Display impl IS `as_str` — pinning this lets future callers
249 /// reach for either projection without drift. If a reviewer
250 /// accidentally re-introduces an inline match in Display, this test
251 /// would fail the moment a variant rename touches one site but not
252 /// the other.
253 #[test]
254 fn must_reach_phase_display_matches_as_str() {
255 for kind in MustReachPhase::ALL {
256 assert_eq!(kind.to_string(), kind.as_str());
257 }
258 }
259
260 /// `FromStr` rejects strings that aren't in the canonical
261 /// projection — lowercased / typo / non-checkpoint phase names —
262 /// and the error echoes the input verbatim so the operator-facing
263 /// diagnostic carries the offending value, not a normalized form.
264 /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
265 /// (which are legal `ProcessPhase`s but NOT valid
266 /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
267 /// whole point of the closed subset. The empty-input arm is
268 /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
269 /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
270 /// verbatim-echo contract on the [`UnknownMustReachPhase`]
271 /// newtype, which the trait's `make_unknown` can't see, AND the
272 /// closed-subset contract (non-checkpoint phases reject) the
273 /// trait's structural surface can't express.
274 #[test]
275 fn unknown_must_reach_phase_errors() {
276 use std::str::FromStr;
277 for bad in [
278 "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
279 ] {
280 let err = MustReachPhase::from_str(bad).unwrap_err();
281 assert_eq!(err.0, bad, "error payload should echo input verbatim");
282 }
283 }
284
285 /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
286 /// impl agrees with the typed `as_process_phase()` projection it
287 /// delegates to, for every variant. A regression that re-introduces
288 /// an inline match in the `From` impl fails here the moment
289 /// `as_process_phase` is the source of truth. Pairs with the
290 /// `as_str` cross-crate test above — together they pin that the
291 /// projection's value AND wire-format are coherent.
292 #[test]
293 fn must_reach_phase_from_delegates_to_as_process_phase() {
294 for kind in MustReachPhase::ALL {
295 let via_from: ProcessPhase = kind.into();
296 assert_eq!(
297 via_from,
298 kind.as_process_phase(),
299 "From<MustReachPhase> drift for {kind:?}",
300 );
301 }
302 }
303
304 /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
305 /// `ProcessPhase` that is `is_running()` — i.e. one of the live
306 /// gating checkpoints (`Running` or `Attested`). This pins the
307 /// closed subset's invariant at the type level: a future
308 /// `MustReachPhase::Released` (e.g. wait for the target to reach
309 /// `Reaped`) would FAIL this test, forcing the author to either
310 /// rename the predicate (`is_running` is wrong for that case) or
311 /// reconsider whether `MustReachPhase` is the right surface (it
312 /// shouldn't be — `Released` belongs on a separate "wait for
313 /// terminal-reached gate" closed set). The compiler enforces
314 /// closure-on-arity; this test enforces closure-on-semantics.
315 #[test]
316 fn must_reach_phase_projects_only_to_live_checkpoints() {
317 for kind in MustReachPhase::ALL {
318 let p = kind.as_process_phase();
319 assert!(
320 p.is_running(),
321 "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
322 );
323 }
324 }
325
326 /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
327 /// to distinct `ProcessPhase` values. Pairing this with the subset
328 /// contract above forces a future variant addition to land on a
329 /// fresh live checkpoint — collapsing two `MustReachPhase` variants
330 /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
331 /// silently makes `from` lossy, which `tatara-reconciler::boundary::
332 /// check_depends_on`'s diagnostic ("need {required}") would
333 /// quietly degrade.
334 #[test]
335 fn must_reach_phase_projection_is_injective() {
336 let mut seen = std::collections::HashSet::new();
337 for kind in MustReachPhase::ALL {
338 let p = kind.as_process_phase();
339 assert!(
340 seen.insert(p),
341 "MustReachPhase projection collision: {kind:?} → {p:?}",
342 );
343 }
344 assert_eq!(seen.len(), MustReachPhase::ALL.len());
345 }
346}