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/// Slice-level `(MustReachPhase, presence)` probe on any `&[DependsOn]`
139/// — the ONE substrate primitive that owns the
140/// `.iter().any(|d| d.must_reach == K)` walk shape past the ★★
141/// PRIME-DIRECTIVE ≥ 2 duplication threshold on the `Vec<DependsOn>`
142/// axis. Callers compose the answer they want on top:
143/// `spec.depends_on.has_must_reach(kind)` for the point-domain
144/// `must-reach-<kind>` require-tag family, a coherence check that
145/// wants "does any depended-on Process have to reach `Attested`
146/// before this one proceeds", an editor completion listing which
147/// `MustReachPhase` checkpoints the operator authored — every future
148/// consumer reaches this ONE primitive through
149/// `slice.has_must_reach(k)` instead of restating the `.iter().any`
150/// closure body.
151///
152/// # Sibling to [`crate::boundary::ConditionSliceExt::has_kind`]
153///
154/// Same axis, same shape, second instance in the workspace-wide
155/// slice-level closed-set-driven presence-probe algebra:
156/// `ConditionSliceExt::has_kind` owns the `(&[Condition],
157/// ConditionKind) -> bool` walk over the boundary's per-side vectors;
158/// `DependsOnSliceExt::has_must_reach` owns the `(&[DependsOn],
159/// MustReachPhase) -> bool` walk over the spec's dependency vector.
160/// Both live one composition boundary below the tagged-union-parent
161/// probes (`Intent::has`, `Lifetime::has`,
162/// `Boundary::has_condition_kind`) at the (`&self`, `K`) → `bool`
163/// signature, so a future normalization at the slice-level probe shape
164/// (widening the return to `Option<&DependsOn>` for deeper
165/// diagnostics, adding a debug-build assertion on redundant duplicate
166/// entries, switching to a linear scan that also counts matches) lands
167/// at ONE site here and every downstream `slice.has_must_reach(K)`
168/// callsite picks it up mechanically.
169///
170/// # Compounding
171///
172/// The `must-reach-<kind>` require-tag prefix family in
173/// `tatara-reconciler::bin::tatara-check` composes this primitive with
174/// the closed-set `FromStr` autoderived on [`MustReachPhase`] through
175/// the `strip_and_classify_prefixed_kind` substrate to publish a
176/// fourth closed-set-driven prefix family byte-for-byte symmetrical
177/// with `intent-<kind>` / `lifetime-<kind>` / `condition-<kind>`. A
178/// future [`MustReachPhase`] variant added to `ALL` (a hypothetical
179/// `Released` checkpoint that waits for the target to have reached a
180/// terminal exit; see the SUBSET CONTRACT test on
181/// `must_reach_phase_projects_only_to_live_checkpoints` for the
182/// semantic gate that guards this) reaches every downstream through
183/// the SAME closed-set walk with no per-caller edit.
184///
185/// Theory anchor: THEORY.md §II.1 invariant 5 — composition preserves
186/// proofs; the per-slice `must_reach` walk lives at ONE substrate site
187/// so every downstream (require-tag classifier, coherence check,
188/// editor completion) binds through the SAME shape rather than
189/// restating the `.iter().any(|d| d.must_reach == K)` closure body at
190/// each callsite. THEORY.md §VI.1 — generation over composition; a
191/// future [`MustReachPhase`] variant lands at ONE `ALL` entry + ONE
192/// `as_str` arm on the closed set and the presence probe picks it up
193/// mechanically without further per-consumer edits.
194pub trait DependsOnSliceExt {
195 /// True iff at least one [`DependsOn`] in this slice gates on the
196 /// given [`MustReachPhase`] checkpoint. The single-slice
197 /// presence probe every consumer of the `(Vec<DependsOn>,
198 /// MustReachPhase) -> bool` shape composes against.
199 fn has_must_reach(&self, kind: MustReachPhase) -> bool;
200}
201
202impl DependsOnSliceExt for [DependsOn] {
203 fn has_must_reach(&self, kind: MustReachPhase) -> bool {
204 self.iter().any(|d| d.must_reach == kind)
205 }
206}
207
208/// Signal policy — how the Process responds to signals.
209#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
210#[serde(rename_all = "camelCase")]
211pub struct SignalPolicy {
212 /// Grace before escalating SIGTERM → SIGKILL.
213 #[serde(default = "crate::serde_defaults::default_sigterm_grace_seconds")]
214 pub sigterm_grace_seconds: u32,
215 /// Permit force-reap via SIGKILL (default: allow).
216 #[serde(default = "crate::serde_defaults::default_true")]
217 pub sigkill_force: bool,
218 /// How SIGHUP is handled.
219 #[serde(default)]
220 pub sighup_strategy: SighupStrategy,
221 /// Start suspended — requires SIGCONT to transition past Forking.
222 #[serde(default)]
223 pub start_suspended: bool,
224}
225
226impl Default for SignalPolicy {
227 fn default() -> Self {
228 Self {
229 sigterm_grace_seconds: crate::serde_defaults::default_sigterm_grace_seconds(),
230 sigkill_force: true,
231 sighup_strategy: SighupStrategy::default(),
232 start_suspended: false,
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn must_reach_default_is_attested() {
243 assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
244 }
245
246 #[test]
247 fn signal_policy_defaults() {
248 let p = SignalPolicy::default();
249 assert_eq!(p.sigterm_grace_seconds, 480);
250 assert!(p.sigkill_force);
251 assert!(!p.start_suspended);
252 }
253
254 // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
255 // as_process_phase) ──────────────────────────────────────────────
256
257 /// Structural well-formedness of [`MustReachPhase`] as a
258 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
259 /// testkit lift that pins all three structural invariants (`ALL`
260 /// is non-empty, every variant round-trips through `label ↔
261 /// parse_label`, labels are pairwise distinct, `""` is outside the
262 /// closed set) at ONE call site. Replaces the hand-derived
263 /// `must_reach_phase_all_is_unique_and_complete` +
264 /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
265 /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
266 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
267 /// exercises the same code path the reconciler hits when parsing
268 /// a CRD `enum:`-validated value back to the typed checkpoint.
269 #[test]
270 fn must_reach_phase_is_well_formed_closed_set() {
271 tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
272 }
273
274 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
275 /// output verbatim for every variant. A future variant rename (or
276 /// an `as_str` arm typo) lands here at one site.
277 #[test]
278 fn must_reach_phase_as_str_matches_serde() {
279 crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
280 }
281
282 /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
283 /// matches the canonical `ProcessPhase::as_str()` of the phase it
284 /// projects to. The two enums share the PascalCase wire format
285 /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
286 /// safe gating checkpoints; a rename on either side (a phase
287 /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
288 /// surfaces here at one site, not buried in a reconciler diagnostic
289 /// that quietly drifted away from the typed-phase surface.
290 #[test]
291 fn must_reach_phase_as_str_matches_process_phase_as_str() {
292 for kind in MustReachPhase::ALL {
293 assert_eq!(
294 kind.as_str(),
295 kind.as_process_phase().as_str(),
296 "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
297 );
298 }
299 }
300
301 /// The Display impl IS `as_str` — pinning this lets future callers
302 /// reach for either projection without drift. If a reviewer
303 /// accidentally re-introduces an inline match in Display, this test
304 /// would fail the moment a variant rename touches one site but not
305 /// the other.
306 #[test]
307 fn must_reach_phase_display_matches_as_str() {
308 crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
309 }
310
311 /// `FromStr` rejects strings that aren't in the canonical
312 /// projection — lowercased / typo / non-checkpoint phase names —
313 /// and the error echoes the input verbatim so the operator-facing
314 /// diagnostic carries the offending value, not a normalized form.
315 /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
316 /// (which are legal `ProcessPhase`s but NOT valid
317 /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
318 /// whole point of the closed subset. The empty-input arm is
319 /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
320 /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
321 /// verbatim-echo contract on the [`UnknownMustReachPhase`]
322 /// newtype, which the trait's `make_unknown` can't see, AND the
323 /// closed-subset contract (non-checkpoint phases reject) the
324 /// trait's structural surface can't express.
325 #[test]
326 fn unknown_must_reach_phase_errors() {
327 use std::str::FromStr;
328 for bad in [
329 "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
330 ] {
331 let err = MustReachPhase::from_str(bad).unwrap_err();
332 assert_eq!(err.0, bad, "error payload should echo input verbatim");
333 }
334 }
335
336 /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
337 /// impl agrees with the typed `as_process_phase()` projection it
338 /// delegates to, for every variant. A regression that re-introduces
339 /// an inline match in the `From` impl fails here the moment
340 /// `as_process_phase` is the source of truth. Pairs with the
341 /// `as_str` cross-crate test above — together they pin that the
342 /// projection's value AND wire-format are coherent.
343 #[test]
344 fn must_reach_phase_from_delegates_to_as_process_phase() {
345 for kind in MustReachPhase::ALL {
346 let via_from: ProcessPhase = kind.into();
347 assert_eq!(
348 via_from,
349 kind.as_process_phase(),
350 "From<MustReachPhase> drift for {kind:?}",
351 );
352 }
353 }
354
355 /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
356 /// `ProcessPhase` that is `is_running()` — i.e. one of the live
357 /// gating checkpoints (`Running` or `Attested`). This pins the
358 /// closed subset's invariant at the type level: a future
359 /// `MustReachPhase::Released` (e.g. wait for the target to reach
360 /// `Reaped`) would FAIL this test, forcing the author to either
361 /// rename the predicate (`is_running` is wrong for that case) or
362 /// reconsider whether `MustReachPhase` is the right surface (it
363 /// shouldn't be — `Released` belongs on a separate "wait for
364 /// terminal-reached gate" closed set). The compiler enforces
365 /// closure-on-arity; this test enforces closure-on-semantics.
366 #[test]
367 fn must_reach_phase_projects_only_to_live_checkpoints() {
368 for kind in MustReachPhase::ALL {
369 let p = kind.as_process_phase();
370 assert!(
371 p.is_running(),
372 "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
373 );
374 }
375 }
376
377 /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
378 /// to distinct `ProcessPhase` values. Pairing this with the subset
379 /// contract above forces a future variant addition to land on a
380 /// fresh live checkpoint — collapsing two `MustReachPhase` variants
381 /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
382 /// silently makes `from` lossy, which `tatara-reconciler::boundary::
383 /// check_depends_on`'s diagnostic ("need {required}") would
384 /// quietly degrade.
385 #[test]
386 fn must_reach_phase_projection_is_injective() {
387 let mut seen = std::collections::HashSet::new();
388 for kind in MustReachPhase::ALL {
389 let p = kind.as_process_phase();
390 assert!(
391 seen.insert(p),
392 "MustReachPhase projection collision: {kind:?} → {p:?}",
393 );
394 }
395 assert_eq!(seen.len(), MustReachPhase::ALL.len());
396 }
397
398 // ── DependsOnSliceExt::has_must_reach substrate pins ─────────────
399 //
400 // Fail-before-pass-after granularity: `DependsOnSliceExt` did not
401 // exist before this commit — the `(&[DependsOn], MustReachPhase)
402 // -> bool` walk did not have a substrate owner yet. The lift places
403 // the per-slice presence probe on ONE site so a future consumer
404 // (require-tag classifier, coherence check, editor completion)
405 // composes against the SAME primitive rather than restating the
406 // `.iter().any(|d| d.must_reach == K)` closure body at each site.
407 // The tests below sweep the (empty, per-variant, multi-entry) matrix
408 // so a variant added to `MustReachPhase::ALL` without a matching
409 // primitive arm surfaces at rustc's exhaustiveness gate on the ALL
410 // literal (arity forced by `[Self; 2]`) rather than as a silent
411 // false-positive at every downstream `slice.has_must_reach(K)`
412 // callsite.
413
414 fn dep_with(must_reach: MustReachPhase) -> DependsOn {
415 DependsOn {
416 name: "target".to_string(),
417 namespace: None,
418 must_reach,
419 }
420 }
421
422 /// EMPTY-SLICE pin — an empty `&[DependsOn]` returns `false` for
423 /// EVERY [`MustReachPhase`]. Sweep `MustReachPhase::ALL` so a new
424 /// variant added without a matching arm in the primitive surfaces
425 /// at rustc's exhaustiveness gate on the ALL literal (arity forced
426 /// by `[Self; 2]`) rather than as a silent false-positive at every
427 /// downstream callsite composing this primitive.
428 #[test]
429 fn depends_on_slice_has_must_reach_returns_false_on_empty_slice_for_every_kind() {
430 let empty: &[DependsOn] = &[];
431 for kind in MustReachPhase::ALL {
432 assert!(
433 !empty.has_must_reach(kind),
434 "empty slice must return false for {kind:?}",
435 );
436 }
437 }
438
439 /// PER-VARIANT pin — a single-element slice returns `true` for
440 /// exactly the checkpoint it gates on, `false` for every other
441 /// variant. Sweep the `ALL × ALL` cross so a regression that
442 /// (a) hard-coded the arm to a single kind (silently returning
443 /// true for every populated slice regardless of query kind), or
444 /// (b) matched on [`DependsOn::name`] instead of
445 /// [`DependsOn::must_reach`] fails HERE at the substrate primitive.
446 #[test]
447 fn depends_on_slice_has_must_reach_reads_must_reach_field_per_variant() {
448 for populated in MustReachPhase::ALL {
449 let slice = [dep_with(populated)];
450 for query in MustReachPhase::ALL {
451 let expected = query == populated;
452 assert_eq!(
453 slice.has_must_reach(query),
454 expected,
455 "populated={populated:?}: query {query:?} drifted",
456 );
457 }
458 }
459 }
460
461 /// MULTI-ENTRY pin — a slice with multiple entries returns `true`
462 /// for every kind that appears at any position (existential
463 /// quantifier over the slice), `false` for kinds that appear at
464 /// no position. Locks the `any` semantics so a regression that
465 /// collapsed to a `first`-only probe (`slice.first().map_or(false,
466 /// |d| d.must_reach == kind)`) fails here even though the
467 /// single-element per-variant pin above passes.
468 #[test]
469 fn depends_on_slice_has_must_reach_scans_beyond_the_first_position() {
470 // Two entries with distinct checkpoints — the `Attested` entry
471 // sits at index 1, so a `first`-only regression on the
472 // `Running`-at-index-0 arrangement returns the wrong answer for
473 // an `Attested` query.
474 let slice = [
475 dep_with(MustReachPhase::Running),
476 dep_with(MustReachPhase::Attested),
477 ];
478 for present in MustReachPhase::ALL {
479 assert!(
480 slice.has_must_reach(present),
481 "kind at any position must resolve true: {present:?}",
482 );
483 }
484 // Single-checkpoint slice — the OTHER variant must resolve
485 // false. Pins the negative arm of the existential quantifier so
486 // a regression that widened the probe (e.g. `_ => true` catchall)
487 // fails here.
488 let running_only = [
489 dep_with(MustReachPhase::Running),
490 dep_with(MustReachPhase::Running),
491 ];
492 assert!(
493 !running_only.has_must_reach(MustReachPhase::Attested),
494 "kind absent from every position must resolve false",
495 );
496 }
497}