Skip to main content

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}