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 = "crate::serde_defaults::default_sigterm_grace_seconds")]
144 pub sigterm_grace_seconds: u32,
145 /// Permit force-reap via SIGKILL (default: allow).
146 #[serde(default = "crate::serde_defaults::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: crate::serde_defaults::default_sigterm_grace_seconds(),
160 sigkill_force: true,
161 sighup_strategy: SighupStrategy::default(),
162 start_suspended: false,
163 }
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn must_reach_default_is_attested() {
173 assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
174 }
175
176 #[test]
177 fn signal_policy_defaults() {
178 let p = SignalPolicy::default();
179 assert_eq!(p.sigterm_grace_seconds, 480);
180 assert!(p.sigkill_force);
181 assert!(!p.start_suspended);
182 }
183
184 // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
185 // as_process_phase) ──────────────────────────────────────────────
186
187 /// Structural well-formedness of [`MustReachPhase`] as a
188 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
189 /// testkit lift that pins all three structural invariants (`ALL`
190 /// is non-empty, every variant round-trips through `label ↔
191 /// parse_label`, labels are pairwise distinct, `""` is outside the
192 /// closed set) at ONE call site. Replaces the hand-derived
193 /// `must_reach_phase_all_is_unique_and_complete` +
194 /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
195 /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
196 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
197 /// exercises the same code path the reconciler hits when parsing
198 /// a CRD `enum:`-validated value back to the typed checkpoint.
199 #[test]
200 fn must_reach_phase_is_well_formed_closed_set() {
201 tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
202 }
203
204 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
205 /// output verbatim for every variant. A future variant rename (or
206 /// an `as_str` arm typo) lands here at one site.
207 #[test]
208 fn must_reach_phase_as_str_matches_serde() {
209 crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
210 }
211
212 /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
213 /// matches the canonical `ProcessPhase::as_str()` of the phase it
214 /// projects to. The two enums share the PascalCase wire format
215 /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
216 /// safe gating checkpoints; a rename on either side (a phase
217 /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
218 /// surfaces here at one site, not buried in a reconciler diagnostic
219 /// that quietly drifted away from the typed-phase surface.
220 #[test]
221 fn must_reach_phase_as_str_matches_process_phase_as_str() {
222 for kind in MustReachPhase::ALL {
223 assert_eq!(
224 kind.as_str(),
225 kind.as_process_phase().as_str(),
226 "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
227 );
228 }
229 }
230
231 /// The Display impl IS `as_str` — pinning this lets future callers
232 /// reach for either projection without drift. If a reviewer
233 /// accidentally re-introduces an inline match in Display, this test
234 /// would fail the moment a variant rename touches one site but not
235 /// the other.
236 #[test]
237 fn must_reach_phase_display_matches_as_str() {
238 crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
239 }
240
241 /// `FromStr` rejects strings that aren't in the canonical
242 /// projection — lowercased / typo / non-checkpoint phase names —
243 /// and the error echoes the input verbatim so the operator-facing
244 /// diagnostic carries the offending value, not a normalized form.
245 /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
246 /// (which are legal `ProcessPhase`s but NOT valid
247 /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
248 /// whole point of the closed subset. The empty-input arm is
249 /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
250 /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
251 /// verbatim-echo contract on the [`UnknownMustReachPhase`]
252 /// newtype, which the trait's `make_unknown` can't see, AND the
253 /// closed-subset contract (non-checkpoint phases reject) the
254 /// trait's structural surface can't express.
255 #[test]
256 fn unknown_must_reach_phase_errors() {
257 use std::str::FromStr;
258 for bad in [
259 "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
260 ] {
261 let err = MustReachPhase::from_str(bad).unwrap_err();
262 assert_eq!(err.0, bad, "error payload should echo input verbatim");
263 }
264 }
265
266 /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
267 /// impl agrees with the typed `as_process_phase()` projection it
268 /// delegates to, for every variant. A regression that re-introduces
269 /// an inline match in the `From` impl fails here the moment
270 /// `as_process_phase` is the source of truth. Pairs with the
271 /// `as_str` cross-crate test above — together they pin that the
272 /// projection's value AND wire-format are coherent.
273 #[test]
274 fn must_reach_phase_from_delegates_to_as_process_phase() {
275 for kind in MustReachPhase::ALL {
276 let via_from: ProcessPhase = kind.into();
277 assert_eq!(
278 via_from,
279 kind.as_process_phase(),
280 "From<MustReachPhase> drift for {kind:?}",
281 );
282 }
283 }
284
285 /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
286 /// `ProcessPhase` that is `is_running()` — i.e. one of the live
287 /// gating checkpoints (`Running` or `Attested`). This pins the
288 /// closed subset's invariant at the type level: a future
289 /// `MustReachPhase::Released` (e.g. wait for the target to reach
290 /// `Reaped`) would FAIL this test, forcing the author to either
291 /// rename the predicate (`is_running` is wrong for that case) or
292 /// reconsider whether `MustReachPhase` is the right surface (it
293 /// shouldn't be — `Released` belongs on a separate "wait for
294 /// terminal-reached gate" closed set). The compiler enforces
295 /// closure-on-arity; this test enforces closure-on-semantics.
296 #[test]
297 fn must_reach_phase_projects_only_to_live_checkpoints() {
298 for kind in MustReachPhase::ALL {
299 let p = kind.as_process_phase();
300 assert!(
301 p.is_running(),
302 "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
303 );
304 }
305 }
306
307 /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
308 /// to distinct `ProcessPhase` values. Pairing this with the subset
309 /// contract above forces a future variant addition to land on a
310 /// fresh live checkpoint — collapsing two `MustReachPhase` variants
311 /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
312 /// silently makes `from` lossy, which `tatara-reconciler::boundary::
313 /// check_depends_on`'s diagnostic ("need {required}") would
314 /// quietly degrade.
315 #[test]
316 fn must_reach_phase_projection_is_injective() {
317 let mut seen = std::collections::HashSet::new();
318 for kind in MustReachPhase::ALL {
319 let p = kind.as_process_phase();
320 assert!(
321 seen.insert(p),
322 "MustReachPhase projection collision: {kind:?} → {p:?}",
323 );
324 }
325 assert_eq!(seen.len(), MustReachPhase::ALL.len());
326 }
327}