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
237impl SignalPolicy {
238    /// Closed-set-driven presence probe — does this [`SignalPolicy`] carry
239    /// the given [`SighupStrategy`] discriminator on its
240    /// [`Self::sighup_strategy`] slot? The ONE substrate primitive that
241    /// owns the `(SignalPolicy, SighupStrategy) -> bool` scalar-carrier
242    /// walk shape.
243    ///
244    /// # Third representation kind on the presence-probe axis
245    ///
246    /// The workspace-wide closed-set-driven presence-probe algebra spans
247    /// three underlying representation kinds; every downstream consumer
248    /// composes through the SAME `has(kind: K) -> bool` shape regardless
249    /// of the field's Rust type:
250    ///
251    /// - **Option-slot** (populated-slot semantics) —
252    ///   [`crate::intent::Intent::has`] and
253    ///   [`crate::lifetime::Lifetime::has`], both bodies
254    ///   `kind.select(self).is_some()` over a tagged-union parent whose
255    ///   discriminator's `select` projects onto an `Option<&V>`.
256    /// - **Slice** (walk-a-Vec semantics) —
257    ///   [`crate::boundary::ConditionSliceExt::has_kind`] on
258    ///   `&[Condition]` and [`crate::spec::DependsOnSliceExt::has_must_reach`]
259    ///   on `&[DependsOn]`, both bodies `self.iter().any(|c| c.kind ==
260    ///   kind)` over a per-entry discriminator field.
261    /// - **Scalar** (variant-equality semantics) — THIS primitive on
262    ///   `spec.signals.sighup_strategy`, body `self.sighup_strategy ==
263    ///   kind` over a non-Option, non-Vec closed-set-discriminator field.
264    ///   [`SighupStrategy`] is a `#[derive(DeriveClosedSet)]` implementor
265    ///   with a `Default` impl, so the field is ALWAYS one of the ALL
266    ///   variants — there is no absent state to detect, and the probe
267    ///   answers "does the carrier's variant equal this discriminator"
268    ///   rather than "is this slot populated".
269    ///
270    /// # Semantics — VARIANT match, not POPULATED slot
271    ///
272    /// `has_sighup_strategy(kind)` returns `true` iff
273    /// `self.sighup_strategy == kind`. On a [`SignalPolicy::default`]
274    /// (`sighup_strategy: SighupStrategy::default() = Reconverge`) the
275    /// probe returns `true` for [`SighupStrategy::Reconverge`] and
276    /// `false` for every other variant — distinct from the Option-slot
277    /// axis where a default carrier returns `false` for EVERY kind. The
278    /// [`SighupStrategy::default`] arm's answer is legitimate operator
279    /// signal: a Process that left `:signals :sighupStrategy` at the
280    /// substrate default IS configured for `Reconverge`, and a
281    /// `:requires (sighup-Reconverge)` should pass; only an operator who
282    /// deliberately overrode the strategy to `Restart` or `Noop` fails
283    /// the tag on this axis.
284    ///
285    /// # Sibling to the presence-probe algebra
286    ///
287    /// Same shape (`has(kind)`), same axis (closed-set discriminator on
288    /// a `ProcessSpec` field), same operator-facing answer (does this
289    /// spec carry this discriminator on this axis). A future
290    /// unification (a trait for closed-set-driven presence probes that
291    /// admits all three representation kinds — Option-slot,
292    /// slice, scalar) lands as ONE peer trait with every current
293    /// implementor picking up the trait default in lockstep.
294    ///
295    /// # Compounding
296    ///
297    /// A future closed-set-discriminator scalar field on `ProcessSpec`
298    /// (or any of its nested structs) that wants a
299    /// `<prefix>-<kind>` require-tag family — a
300    /// `verification-phase-<kind>` on `spec.compliance.<binding>.phase`,
301    /// a `routing-form-<kind>` on `spec.routing.as_ref().map(|r|
302    /// r.form)`, a future `intent-kind` scalar discriminator on any
303    /// scalar-enum spec field — lands as ONE peer inherent method with
304    /// the same one-line `self.<field> == kind` body and routes through
305    /// the same `strip_and_classify_prefixed_kind::<K, _>` shape in
306    /// `tatara-check`.
307    ///
308    /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
309    /// preserves proofs; the scalar-carrier presence-probe body lives at
310    /// ONE substrate site so every downstream
311    /// (`sighup-<kind>` require-tag family in tatara-check, closed-set
312    /// audit dispatchers, future variant additions on
313    /// [`SighupStrategy`]) binds through the SAME shape rather than
314    /// restating the `signals.sighup_strategy == kind` closure body at
315    /// each callsite. THEORY.md §VI.1 — generation over composition;
316    /// a future [`SighupStrategy`] variant (a `Suspend` that maps SIGHUP
317    /// onto [`crate::phase::ProcessPhase::Zombie`] via
318    /// [`SighupStrategy::sighup_target`]) lands at ONE `ALL` entry + ONE
319    /// `as_str` arm on the closed set and the probe picks it up
320    /// mechanically without further per-consumer edits.
321    #[must_use]
322    pub fn has_sighup_strategy(&self, kind: SighupStrategy) -> bool {
323        self.sighup_strategy == kind
324    }
325
326    /// Derived-Option-child presence probe on the SIGHUP-target
327    /// projection — `true` iff this policy's stored
328    /// [`Self::sighup_strategy`] transitions this Process INTO
329    /// `phase` on SIGHUP reception (as read through
330    /// [`SighupStrategy::has_target`], which composes
331    /// [`SighupStrategy::sighup_target`] with an
332    /// `== Some(phase)` gate).
333    ///
334    /// The one-line collapse of the `signals.sighup_strategy.sighup_target()
335    /// == Some(phase)` closure body lifted to ONE substrate owner past
336    /// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold — the
337    /// `sighup-target-<phase>` require-tag prefix family in
338    /// [`tatara-check`]'s point-domain require-tag classifier is the
339    /// first workspace-wide consumer. Composes through
340    /// [`SighupStrategy::has_target`] so a regression in the
341    /// [`SighupStrategy::sighup_target`] projection surfaces at ONE
342    /// substrate site and every downstream (this method, the
343    /// require-tag classifier, closed-set audit dispatchers walking
344    /// [`crate::phase::ProcessPhase::ALL`] against SIGHUP transition
345    /// semantics) inherits the shift.
346    ///
347    /// # Semantics — DERIVED sighup_target, not raw strategy equality
348    ///
349    /// Sibling to [`Self::has_sighup_strategy`] on the SAME
350    /// [`Self::sighup_strategy`] field: `has_sighup_strategy` asks
351    /// "does this policy CARRY the queried strategy literal" (raw
352    /// stored-scalar equality); `has_sighup_target` asks "would this
353    /// policy TRANSITION the Process into the queried phase on
354    /// SIGHUP" (compound `sighup_target` projection). The two probes
355    /// coexist because a future
356    /// [`SighupStrategy`] variant that reuses an existing target
357    /// (a hypothetical `Refresh` mapping to `Reconverging`) would
358    /// satisfy `has_sighup_target(Reconverging)` alongside the
359    /// existing `Reconverge`-carrying policies — distinct stored
360    /// variants, same derived target — while
361    /// `has_sighup_strategy(Reconverge)` stays keyed strictly on
362    /// the stored variant.
363    ///
364    /// # Sibling scalar-carrier probes
365    ///
366    /// * [`Self::has_sighup_strategy`] — required parent × defaulted
367    ///   scalar child (stored). SAME parent, SAME field, RAW
368    ///   discriminator equality.
369    /// * [`crate::routing::RoutingSpec::has_form`] — Option-parent ×
370    ///   derived-scalar-child. Peer on the "derived child" axis, but
371    ///   the derived projection returns raw [`RoutingForm`] rather
372    ///   than `Option<RoutingForm>` (there is no `None`-projecting
373    ///   variant).
374    /// * THIS — required-parent × derived-Option-child. First
375    ///   occupant on the (required-parent × derived-Option-child)
376    ///   corner of the presence-probe algebra: the child projection
377    ///   returns `Option<K>` because one variant
378    ///   ([`SighupStrategy::Noop`]) transitions into no phase at all,
379    ///   and the `None` arm short-circuits every query to `false`.
380    ///
381    /// # Compounding
382    ///
383    /// The point-domain require-tag surface in
384    /// `tatara-reconciler::bin::tatara-check` composes this primitive
385    /// with the closed-set [`crate::phase::ProcessPhase`]'s
386    /// autoderived `FromStr` through the
387    /// `strip_and_classify_prefixed_kind` substrate to publish the
388    /// TWENTY-SIXTH closed-set-driven prefix family in the point-
389    /// domain classifier's dispatch table
390    /// (`sighup-target-<phase>`), byte-for-byte symmetrical with the
391    /// sibling `sighup-<kind>` family that composes through
392    /// [`Self::has_sighup_strategy`].
393    ///
394    /// A future [`SighupStrategy`] variant reaches this probe
395    /// through ONE `ALL` entry + one `as_str` arm + one
396    /// `sighup_target` arm alone — no per-caller edit at this
397    /// composition, no per-consumer restatement of the
398    /// `signals.sighup_strategy.sighup_target() == Some(phase)` closure
399    /// body. A future [`crate::phase::ProcessPhase`] variant that a
400    /// hypothetical fresh strategy targets is reached by pairing a
401    /// new `sighup_target` arm with a new `ALL` entry — again ONE
402    /// substrate edit and every downstream inherits the shift.
403    ///
404    /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
405    /// preserves proofs; the derived-Option-child presence-probe
406    /// composition (`self.sighup_strategy.has_target(phase)`) lives
407    /// at ONE substrate site so every downstream
408    /// (`sighup-target-<phase>` require-tag family in tatara-check,
409    /// closed-set audit dispatchers, future variant additions on
410    /// either [`SighupStrategy`] or
411    /// [`crate::phase::ProcessPhase`]) binds through the SAME
412    /// `has_sighup_target(phase)` shape rather than restating the
413    /// `signals.sighup_strategy.sighup_target() == Some(phase)` chain at
414    /// each callsite. THEORY.md §VI.1 — generation over composition;
415    /// the [`SighupStrategy::sighup_target`] projection is the ONE
416    /// per-variant edit site every future strategy variant reaches
417    /// through, and this method inherits the shift automatically.
418    #[must_use]
419    pub fn has_sighup_target(&self, phase: ProcessPhase) -> bool {
420        self.sighup_strategy.has_target(phase)
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn must_reach_default_is_attested() {
430        assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
431    }
432
433    #[test]
434    fn signal_policy_defaults() {
435        let p = SignalPolicy::default();
436        assert_eq!(p.sigterm_grace_seconds, 480);
437        assert!(p.sigkill_force);
438        assert!(!p.start_suspended);
439    }
440
441    // ── scalar-carrier presence probe on SignalPolicy × SighupStrategy ──
442    //
443    // Fail-before-pass-after granularity: [`SignalPolicy::has_sighup_strategy`]
444    // did not exist before this commit — every consumer of the
445    // `(SignalPolicy, SighupStrategy) -> bool` scalar-carrier probe
446    // shape restated the `signals.sighup_strategy == kind` closure body
447    // at its own callsite. Post-lift the shape lives at ONE substrate
448    // owner and every downstream (the `sighup-<kind>` require-tag family
449    // in `tatara-check`, future audit dispatchers walking
450    // [`SighupStrategy::ALL`], any future CRD-facing closed-set
451    // discriminator on a scalar `ProcessSpec` field) binds through the
452    // SAME `has(kind)` shape the Option-slot (Intent::has, Lifetime::has)
453    // and slice-level (ConditionSliceExt::has_kind,
454    // DependsOnSliceExt::has_must_reach) primitives publish.
455
456    /// DIAGONAL — for every [`SighupStrategy`] variant, a
457    /// [`SignalPolicy`] whose `sighup_strategy` field is set to that
458    /// variant returns `true` from `has_sighup_strategy` on that same
459    /// variant AND `false` on every other variant. Sweep the
460    /// [`SighupStrategy::ALL`] × ALL cross so a regression that hard-
461    /// coded the arm to a single variant (silently returning `true` on
462    /// every populated policy regardless of query kind) or wired the
463    /// equality to a fixed unrelated field fails HERE at the substrate
464    /// primitive before landing at the operator-facing checks.lisp
465    /// surface.
466    #[test]
467    fn signal_policy_has_sighup_strategy_returns_true_iff_variant_matches() {
468        for populated in SighupStrategy::ALL {
469            let policy = SignalPolicy {
470                sighup_strategy: populated,
471                ..SignalPolicy::default()
472            };
473            for query in SighupStrategy::ALL {
474                assert_eq!(
475                    policy.has_sighup_strategy(query),
476                    query == populated,
477                    "sighup_strategy={populated:?}: query {query:?} classification drifted",
478                );
479            }
480        }
481    }
482
483    /// DEFAULT — a [`SignalPolicy::default`] carries
484    /// `sighup_strategy: SighupStrategy::default() = Reconverge`, so
485    /// the scalar-carrier probe returns `true` on
486    /// [`SighupStrategy::Reconverge`] and `false` on every other
487    /// variant. Distinct from the Option-slot axis where a default
488    /// carrier returns `false` for EVERY kind — pins the
489    /// scalar-vs-option semantic split at ONE narrow substrate site so
490    /// a regression that rewired the probe to Option-slot semantics
491    /// (returning `false` on the default) fails here.
492    #[test]
493    fn signal_policy_has_sighup_strategy_default_probes_reconverge_only() {
494        let policy = SignalPolicy::default();
495        for kind in SighupStrategy::ALL {
496            let expected = kind == SighupStrategy::Reconverge;
497            assert_eq!(
498                policy.has_sighup_strategy(kind),
499                expected,
500                "default policy (sighup_strategy=Reconverge) must return {expected} for {kind:?}",
501            );
502        }
503    }
504
505    // ── derived-Option-child presence probe on SignalPolicy ×
506    //    ProcessPhase (via SighupStrategy::sighup_target) ────────────────
507    //
508    // Fail-before-pass-after granularity: [`SignalPolicy::has_sighup_target`]
509    // did not exist before this commit — every consumer of the compound
510    // `(SignalPolicy, ProcessPhase) -> bool` probe over the derived
511    // SIGHUP-target codomain restated the
512    // `signals.sighup_strategy.sighup_target() == Some(phase)` chain at
513    // its own callsite. Post-lift the shape lives at ONE substrate owner
514    // and every downstream (the `sighup-target-<phase>` require-tag
515    // family in `tatara-check`, future audit dispatchers, future closed-
516    // set-discriminator projections whose codomain is `Option<K>`) binds
517    // through the SAME `has_sighup_target(phase)` shape.
518
519    /// DIAGONAL — for every [`SighupStrategy`] variant, a
520    /// [`SignalPolicy`] carrying that variant returns `true` from
521    /// `has_sighup_target` on the phase [`SighupStrategy::sighup_target`]
522    /// projects to AND `false` on every other phase in
523    /// [`ProcessPhase::ALL`]. Sweep the [`SighupStrategy::ALL`] ×
524    /// [`ProcessPhase::ALL`] cross so a regression that hard-coded the
525    /// composition to a single variant / phase pair (silently returning
526    /// `true` regardless of query phase, or wired the equality to a
527    /// fixed unrelated field) fails HERE at the substrate primitive
528    /// before landing at the operator-facing checks.lisp surface.
529    #[test]
530    fn signal_policy_has_sighup_target_returns_true_iff_projection_matches() {
531        for populated in SighupStrategy::ALL {
532            let policy = SignalPolicy {
533                sighup_strategy: populated,
534                ..SignalPolicy::default()
535            };
536            let expected_target = populated.sighup_target();
537            for phase in ProcessPhase::ALL {
538                let expected = expected_target == Some(phase);
539                assert_eq!(
540                    policy.has_sighup_target(phase),
541                    expected,
542                    "sighup_strategy={populated:?}: query phase={phase:?} \
543                     classification drifted from sighup_target() projection \
544                     {expected_target:?}",
545                );
546            }
547        }
548    }
549
550    /// DEFAULT — a [`SignalPolicy::default`] carries
551    /// `sighup_strategy: SighupStrategy::default() = Reconverge`, whose
552    /// [`SighupStrategy::sighup_target`] projection is
553    /// `Some(ProcessPhase::Reconverging)`. So `has_sighup_target`
554    /// returns `true` on `Reconverging` and `false` on every other
555    /// phase in [`ProcessPhase::ALL`]. Pins the composition through the
556    /// default's canonical `Reconverge` variant so a regression that
557    /// swapped [`Self::has_sighup_target`]'s inner projection (e.g.
558    /// wiring it to a fixed `Exiting`, or to `has_sighup_strategy`
559    /// itself) fails HERE.
560    #[test]
561    fn signal_policy_has_sighup_target_default_probes_reconverging_only() {
562        let policy = SignalPolicy::default();
563        for phase in ProcessPhase::ALL {
564            let expected = phase == ProcessPhase::Reconverging;
565            assert_eq!(
566                policy.has_sighup_target(phase),
567                expected,
568                "default policy (sighup_strategy=Reconverge, target=Reconverging) \
569                 must return {expected} for phase={phase:?}",
570            );
571        }
572    }
573
574    /// NOOP-ARM PIN — a [`SignalPolicy`] carrying
575    /// [`SighupStrategy::Noop`] projects to `None` through
576    /// [`SighupStrategy::sighup_target`], so `has_sighup_target`
577    /// returns `false` for EVERY phase. Distinct from the sibling
578    /// stored-scalar [`Self::has_sighup_strategy`], which returns
579    /// `true` on a `Noop`-carrying policy when queried on
580    /// `SighupStrategy::Noop` itself — pins the derived-Option-child
581    /// vs stored-scalar semantic split at ONE narrow substrate site
582    /// so a regression that projected `Noop` onto a spurious target
583    /// (Zombie / Pending / Failed) fails HERE.
584    #[test]
585    fn signal_policy_has_sighup_target_returns_false_on_noop_for_every_phase() {
586        let policy = SignalPolicy {
587            sighup_strategy: SighupStrategy::Noop,
588            ..SignalPolicy::default()
589        };
590        assert!(policy.has_sighup_strategy(SighupStrategy::Noop));
591        for phase in ProcessPhase::ALL {
592            assert!(
593                !policy.has_sighup_target(phase),
594                "Noop-carrying policy must return false for phase {phase:?}",
595            );
596        }
597    }
598
599    // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
600    //    as_process_phase) ──────────────────────────────────────────────
601
602    /// Structural well-formedness of [`MustReachPhase`] as a
603    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
604    /// testkit lift that pins all three structural invariants (`ALL`
605    /// is non-empty, every variant round-trips through `label ↔
606    /// parse_label`, labels are pairwise distinct, `""` is outside the
607    /// closed set) at ONE call site. Replaces the hand-derived
608    /// `must_reach_phase_all_is_unique_and_complete` +
609    /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
610    /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
611    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
612    /// exercises the same code path the reconciler hits when parsing
613    /// a CRD `enum:`-validated value back to the typed checkpoint.
614    #[test]
615    fn must_reach_phase_is_well_formed_closed_set() {
616        tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
617    }
618
619    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
620    /// output verbatim for every variant. A future variant rename (or
621    /// an `as_str` arm typo) lands here at one site.
622    #[test]
623    fn must_reach_phase_as_str_matches_serde() {
624        crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
625    }
626
627    /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
628    /// matches the canonical `ProcessPhase::as_str()` of the phase it
629    /// projects to. The two enums share the PascalCase wire format
630    /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
631    /// safe gating checkpoints; a rename on either side (a phase
632    /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
633    /// surfaces here at one site, not buried in a reconciler diagnostic
634    /// that quietly drifted away from the typed-phase surface.
635    #[test]
636    fn must_reach_phase_as_str_matches_process_phase_as_str() {
637        for kind in MustReachPhase::ALL {
638            assert_eq!(
639                kind.as_str(),
640                kind.as_process_phase().as_str(),
641                "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
642            );
643        }
644    }
645
646    /// The Display impl IS `as_str` — pinning this lets future callers
647    /// reach for either projection without drift. If a reviewer
648    /// accidentally re-introduces an inline match in Display, this test
649    /// would fail the moment a variant rename touches one site but not
650    /// the other.
651    #[test]
652    fn must_reach_phase_display_matches_as_str() {
653        crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
654    }
655
656    /// `FromStr` rejects strings that aren't in the canonical
657    /// projection — lowercased / typo / non-checkpoint phase names —
658    /// and the error echoes the input verbatim so the operator-facing
659    /// diagnostic carries the offending value, not a normalized form.
660    /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
661    /// (which are legal `ProcessPhase`s but NOT valid
662    /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
663    /// whole point of the closed subset. The empty-input arm is
664    /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
665    /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
666    /// verbatim-echo contract on the [`UnknownMustReachPhase`]
667    /// newtype, which the trait's `make_unknown` can't see, AND the
668    /// closed-subset contract (non-checkpoint phases reject) the
669    /// trait's structural surface can't express.
670    #[test]
671    fn unknown_must_reach_phase_errors() {
672        use std::str::FromStr;
673        for bad in [
674            "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
675        ] {
676            let err = MustReachPhase::from_str(bad).unwrap_err();
677            assert_eq!(err.0, bad, "error payload should echo input verbatim");
678        }
679    }
680
681    /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
682    /// impl agrees with the typed `as_process_phase()` projection it
683    /// delegates to, for every variant. A regression that re-introduces
684    /// an inline match in the `From` impl fails here the moment
685    /// `as_process_phase` is the source of truth. Pairs with the
686    /// `as_str` cross-crate test above — together they pin that the
687    /// projection's value AND wire-format are coherent.
688    #[test]
689    fn must_reach_phase_from_delegates_to_as_process_phase() {
690        for kind in MustReachPhase::ALL {
691            let via_from: ProcessPhase = kind.into();
692            assert_eq!(
693                via_from,
694                kind.as_process_phase(),
695                "From<MustReachPhase> drift for {kind:?}",
696            );
697        }
698    }
699
700    /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
701    /// `ProcessPhase` that is `is_running()` — i.e. one of the live
702    /// gating checkpoints (`Running` or `Attested`). This pins the
703    /// closed subset's invariant at the type level: a future
704    /// `MustReachPhase::Released` (e.g. wait for the target to reach
705    /// `Reaped`) would FAIL this test, forcing the author to either
706    /// rename the predicate (`is_running` is wrong for that case) or
707    /// reconsider whether `MustReachPhase` is the right surface (it
708    /// shouldn't be — `Released` belongs on a separate "wait for
709    /// terminal-reached gate" closed set). The compiler enforces
710    /// closure-on-arity; this test enforces closure-on-semantics.
711    #[test]
712    fn must_reach_phase_projects_only_to_live_checkpoints() {
713        for kind in MustReachPhase::ALL {
714            let p = kind.as_process_phase();
715            assert!(
716                p.is_running(),
717                "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
718            );
719        }
720    }
721
722    /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
723    /// to distinct `ProcessPhase` values. Pairing this with the subset
724    /// contract above forces a future variant addition to land on a
725    /// fresh live checkpoint — collapsing two `MustReachPhase` variants
726    /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
727    /// silently makes `from` lossy, which `tatara-reconciler::boundary::
728    /// check_depends_on`'s diagnostic ("need {required}") would
729    /// quietly degrade.
730    #[test]
731    fn must_reach_phase_projection_is_injective() {
732        let mut seen = std::collections::HashSet::new();
733        for kind in MustReachPhase::ALL {
734            let p = kind.as_process_phase();
735            assert!(
736                seen.insert(p),
737                "MustReachPhase projection collision: {kind:?} → {p:?}",
738            );
739        }
740        assert_eq!(seen.len(), MustReachPhase::ALL.len());
741    }
742
743    // ── DependsOnSliceExt::has_must_reach substrate pins ─────────────
744    //
745    // Fail-before-pass-after granularity: `DependsOnSliceExt` did not
746    // exist before this commit — the `(&[DependsOn], MustReachPhase)
747    // -> bool` walk did not have a substrate owner yet. The lift places
748    // the per-slice presence probe on ONE site so a future consumer
749    // (require-tag classifier, coherence check, editor completion)
750    // composes against the SAME primitive rather than restating the
751    // `.iter().any(|d| d.must_reach == K)` closure body at each site.
752    // The tests below sweep the (empty, per-variant, multi-entry) matrix
753    // so a variant added to `MustReachPhase::ALL` without a matching
754    // primitive arm surfaces at rustc's exhaustiveness gate on the ALL
755    // literal (arity forced by `[Self; 2]`) rather than as a silent
756    // false-positive at every downstream `slice.has_must_reach(K)`
757    // callsite.
758
759    fn dep_with(must_reach: MustReachPhase) -> DependsOn {
760        DependsOn {
761            name: "target".to_string(),
762            namespace: None,
763            must_reach,
764        }
765    }
766
767    /// EMPTY-SLICE pin — an empty `&[DependsOn]` returns `false` for
768    /// EVERY [`MustReachPhase`]. Sweep `MustReachPhase::ALL` so a new
769    /// variant added without a matching arm in the primitive surfaces
770    /// at rustc's exhaustiveness gate on the ALL literal (arity forced
771    /// by `[Self; 2]`) rather than as a silent false-positive at every
772    /// downstream callsite composing this primitive.
773    #[test]
774    fn depends_on_slice_has_must_reach_returns_false_on_empty_slice_for_every_kind() {
775        let empty: &[DependsOn] = &[];
776        for kind in MustReachPhase::ALL {
777            assert!(
778                !empty.has_must_reach(kind),
779                "empty slice must return false for {kind:?}",
780            );
781        }
782    }
783
784    /// PER-VARIANT pin — a single-element slice returns `true` for
785    /// exactly the checkpoint it gates on, `false` for every other
786    /// variant. Sweep the `ALL × ALL` cross so a regression that
787    /// (a) hard-coded the arm to a single kind (silently returning
788    /// true for every populated slice regardless of query kind), or
789    /// (b) matched on [`DependsOn::name`] instead of
790    /// [`DependsOn::must_reach`] fails HERE at the substrate primitive.
791    #[test]
792    fn depends_on_slice_has_must_reach_reads_must_reach_field_per_variant() {
793        for populated in MustReachPhase::ALL {
794            let slice = [dep_with(populated)];
795            for query in MustReachPhase::ALL {
796                let expected = query == populated;
797                assert_eq!(
798                    slice.has_must_reach(query),
799                    expected,
800                    "populated={populated:?}: query {query:?} drifted",
801                );
802            }
803        }
804    }
805
806    /// MULTI-ENTRY pin — a slice with multiple entries returns `true`
807    /// for every kind that appears at any position (existential
808    /// quantifier over the slice), `false` for kinds that appear at
809    /// no position. Locks the `any` semantics so a regression that
810    /// collapsed to a `first`-only probe (`slice.first().map_or(false,
811    /// |d| d.must_reach == kind)`) fails here even though the
812    /// single-element per-variant pin above passes.
813    #[test]
814    fn depends_on_slice_has_must_reach_scans_beyond_the_first_position() {
815        // Two entries with distinct checkpoints — the `Attested` entry
816        // sits at index 1, so a `first`-only regression on the
817        // `Running`-at-index-0 arrangement returns the wrong answer for
818        // an `Attested` query.
819        let slice = [
820            dep_with(MustReachPhase::Running),
821            dep_with(MustReachPhase::Attested),
822        ];
823        for present in MustReachPhase::ALL {
824            assert!(
825                slice.has_must_reach(present),
826                "kind at any position must resolve true: {present:?}",
827            );
828        }
829        // Single-checkpoint slice — the OTHER variant must resolve
830        // false. Pins the negative arm of the existential quantifier so
831        // a regression that widened the probe (e.g. `_ => true` catchall)
832        // fails here.
833        let running_only = [
834            dep_with(MustReachPhase::Running),
835            dep_with(MustReachPhase::Running),
836        ];
837        assert!(
838            !running_only.has_must_reach(MustReachPhase::Attested),
839            "kind absent from every position must resolve false",
840        );
841    }
842}