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
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn must_reach_default_is_attested() {
333        assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
334    }
335
336    #[test]
337    fn signal_policy_defaults() {
338        let p = SignalPolicy::default();
339        assert_eq!(p.sigterm_grace_seconds, 480);
340        assert!(p.sigkill_force);
341        assert!(!p.start_suspended);
342    }
343
344    // ── scalar-carrier presence probe on SignalPolicy × SighupStrategy ──
345    //
346    // Fail-before-pass-after granularity: [`SignalPolicy::has_sighup_strategy`]
347    // did not exist before this commit — every consumer of the
348    // `(SignalPolicy, SighupStrategy) -> bool` scalar-carrier probe
349    // shape restated the `signals.sighup_strategy == kind` closure body
350    // at its own callsite. Post-lift the shape lives at ONE substrate
351    // owner and every downstream (the `sighup-<kind>` require-tag family
352    // in `tatara-check`, future audit dispatchers walking
353    // [`SighupStrategy::ALL`], any future CRD-facing closed-set
354    // discriminator on a scalar `ProcessSpec` field) binds through the
355    // SAME `has(kind)` shape the Option-slot (Intent::has, Lifetime::has)
356    // and slice-level (ConditionSliceExt::has_kind,
357    // DependsOnSliceExt::has_must_reach) primitives publish.
358
359    /// DIAGONAL — for every [`SighupStrategy`] variant, a
360    /// [`SignalPolicy`] whose `sighup_strategy` field is set to that
361    /// variant returns `true` from `has_sighup_strategy` on that same
362    /// variant AND `false` on every other variant. Sweep the
363    /// [`SighupStrategy::ALL`] × ALL cross so a regression that hard-
364    /// coded the arm to a single variant (silently returning `true` on
365    /// every populated policy regardless of query kind) or wired the
366    /// equality to a fixed unrelated field fails HERE at the substrate
367    /// primitive before landing at the operator-facing checks.lisp
368    /// surface.
369    #[test]
370    fn signal_policy_has_sighup_strategy_returns_true_iff_variant_matches() {
371        for populated in SighupStrategy::ALL {
372            let policy = SignalPolicy {
373                sighup_strategy: populated,
374                ..SignalPolicy::default()
375            };
376            for query in SighupStrategy::ALL {
377                assert_eq!(
378                    policy.has_sighup_strategy(query),
379                    query == populated,
380                    "sighup_strategy={populated:?}: query {query:?} classification drifted",
381                );
382            }
383        }
384    }
385
386    /// DEFAULT — a [`SignalPolicy::default`] carries
387    /// `sighup_strategy: SighupStrategy::default() = Reconverge`, so
388    /// the scalar-carrier probe returns `true` on
389    /// [`SighupStrategy::Reconverge`] and `false` on every other
390    /// variant. Distinct from the Option-slot axis where a default
391    /// carrier returns `false` for EVERY kind — pins the
392    /// scalar-vs-option semantic split at ONE narrow substrate site so
393    /// a regression that rewired the probe to Option-slot semantics
394    /// (returning `false` on the default) fails here.
395    #[test]
396    fn signal_policy_has_sighup_strategy_default_probes_reconverge_only() {
397        let policy = SignalPolicy::default();
398        for kind in SighupStrategy::ALL {
399            let expected = kind == SighupStrategy::Reconverge;
400            assert_eq!(
401                policy.has_sighup_strategy(kind),
402                expected,
403                "default policy (sighup_strategy=Reconverge) must return {expected} for {kind:?}",
404            );
405        }
406    }
407
408    // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
409    //    as_process_phase) ──────────────────────────────────────────────
410
411    /// Structural well-formedness of [`MustReachPhase`] as a
412    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
413    /// testkit lift that pins all three structural invariants (`ALL`
414    /// is non-empty, every variant round-trips through `label ↔
415    /// parse_label`, labels are pairwise distinct, `""` is outside the
416    /// closed set) at ONE call site. Replaces the hand-derived
417    /// `must_reach_phase_all_is_unique_and_complete` +
418    /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
419    /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
420    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
421    /// exercises the same code path the reconciler hits when parsing
422    /// a CRD `enum:`-validated value back to the typed checkpoint.
423    #[test]
424    fn must_reach_phase_is_well_formed_closed_set() {
425        tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
426    }
427
428    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
429    /// output verbatim for every variant. A future variant rename (or
430    /// an `as_str` arm typo) lands here at one site.
431    #[test]
432    fn must_reach_phase_as_str_matches_serde() {
433        crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
434    }
435
436    /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
437    /// matches the canonical `ProcessPhase::as_str()` of the phase it
438    /// projects to. The two enums share the PascalCase wire format
439    /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
440    /// safe gating checkpoints; a rename on either side (a phase
441    /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
442    /// surfaces here at one site, not buried in a reconciler diagnostic
443    /// that quietly drifted away from the typed-phase surface.
444    #[test]
445    fn must_reach_phase_as_str_matches_process_phase_as_str() {
446        for kind in MustReachPhase::ALL {
447            assert_eq!(
448                kind.as_str(),
449                kind.as_process_phase().as_str(),
450                "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
451            );
452        }
453    }
454
455    /// The Display impl IS `as_str` — pinning this lets future callers
456    /// reach for either projection without drift. If a reviewer
457    /// accidentally re-introduces an inline match in Display, this test
458    /// would fail the moment a variant rename touches one site but not
459    /// the other.
460    #[test]
461    fn must_reach_phase_display_matches_as_str() {
462        crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
463    }
464
465    /// `FromStr` rejects strings that aren't in the canonical
466    /// projection — lowercased / typo / non-checkpoint phase names —
467    /// and the error echoes the input verbatim so the operator-facing
468    /// diagnostic carries the offending value, not a normalized form.
469    /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
470    /// (which are legal `ProcessPhase`s but NOT valid
471    /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
472    /// whole point of the closed subset. The empty-input arm is
473    /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
474    /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
475    /// verbatim-echo contract on the [`UnknownMustReachPhase`]
476    /// newtype, which the trait's `make_unknown` can't see, AND the
477    /// closed-subset contract (non-checkpoint phases reject) the
478    /// trait's structural surface can't express.
479    #[test]
480    fn unknown_must_reach_phase_errors() {
481        use std::str::FromStr;
482        for bad in [
483            "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
484        ] {
485            let err = MustReachPhase::from_str(bad).unwrap_err();
486            assert_eq!(err.0, bad, "error payload should echo input verbatim");
487        }
488    }
489
490    /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
491    /// impl agrees with the typed `as_process_phase()` projection it
492    /// delegates to, for every variant. A regression that re-introduces
493    /// an inline match in the `From` impl fails here the moment
494    /// `as_process_phase` is the source of truth. Pairs with the
495    /// `as_str` cross-crate test above — together they pin that the
496    /// projection's value AND wire-format are coherent.
497    #[test]
498    fn must_reach_phase_from_delegates_to_as_process_phase() {
499        for kind in MustReachPhase::ALL {
500            let via_from: ProcessPhase = kind.into();
501            assert_eq!(
502                via_from,
503                kind.as_process_phase(),
504                "From<MustReachPhase> drift for {kind:?}",
505            );
506        }
507    }
508
509    /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
510    /// `ProcessPhase` that is `is_running()` — i.e. one of the live
511    /// gating checkpoints (`Running` or `Attested`). This pins the
512    /// closed subset's invariant at the type level: a future
513    /// `MustReachPhase::Released` (e.g. wait for the target to reach
514    /// `Reaped`) would FAIL this test, forcing the author to either
515    /// rename the predicate (`is_running` is wrong for that case) or
516    /// reconsider whether `MustReachPhase` is the right surface (it
517    /// shouldn't be — `Released` belongs on a separate "wait for
518    /// terminal-reached gate" closed set). The compiler enforces
519    /// closure-on-arity; this test enforces closure-on-semantics.
520    #[test]
521    fn must_reach_phase_projects_only_to_live_checkpoints() {
522        for kind in MustReachPhase::ALL {
523            let p = kind.as_process_phase();
524            assert!(
525                p.is_running(),
526                "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
527            );
528        }
529    }
530
531    /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
532    /// to distinct `ProcessPhase` values. Pairing this with the subset
533    /// contract above forces a future variant addition to land on a
534    /// fresh live checkpoint — collapsing two `MustReachPhase` variants
535    /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
536    /// silently makes `from` lossy, which `tatara-reconciler::boundary::
537    /// check_depends_on`'s diagnostic ("need {required}") would
538    /// quietly degrade.
539    #[test]
540    fn must_reach_phase_projection_is_injective() {
541        let mut seen = std::collections::HashSet::new();
542        for kind in MustReachPhase::ALL {
543            let p = kind.as_process_phase();
544            assert!(
545                seen.insert(p),
546                "MustReachPhase projection collision: {kind:?} → {p:?}",
547            );
548        }
549        assert_eq!(seen.len(), MustReachPhase::ALL.len());
550    }
551
552    // ── DependsOnSliceExt::has_must_reach substrate pins ─────────────
553    //
554    // Fail-before-pass-after granularity: `DependsOnSliceExt` did not
555    // exist before this commit — the `(&[DependsOn], MustReachPhase)
556    // -> bool` walk did not have a substrate owner yet. The lift places
557    // the per-slice presence probe on ONE site so a future consumer
558    // (require-tag classifier, coherence check, editor completion)
559    // composes against the SAME primitive rather than restating the
560    // `.iter().any(|d| d.must_reach == K)` closure body at each site.
561    // The tests below sweep the (empty, per-variant, multi-entry) matrix
562    // so a variant added to `MustReachPhase::ALL` without a matching
563    // primitive arm surfaces at rustc's exhaustiveness gate on the ALL
564    // literal (arity forced by `[Self; 2]`) rather than as a silent
565    // false-positive at every downstream `slice.has_must_reach(K)`
566    // callsite.
567
568    fn dep_with(must_reach: MustReachPhase) -> DependsOn {
569        DependsOn {
570            name: "target".to_string(),
571            namespace: None,
572            must_reach,
573        }
574    }
575
576    /// EMPTY-SLICE pin — an empty `&[DependsOn]` returns `false` for
577    /// EVERY [`MustReachPhase`]. Sweep `MustReachPhase::ALL` so a new
578    /// variant added without a matching arm in the primitive surfaces
579    /// at rustc's exhaustiveness gate on the ALL literal (arity forced
580    /// by `[Self; 2]`) rather than as a silent false-positive at every
581    /// downstream callsite composing this primitive.
582    #[test]
583    fn depends_on_slice_has_must_reach_returns_false_on_empty_slice_for_every_kind() {
584        let empty: &[DependsOn] = &[];
585        for kind in MustReachPhase::ALL {
586            assert!(
587                !empty.has_must_reach(kind),
588                "empty slice must return false for {kind:?}",
589            );
590        }
591    }
592
593    /// PER-VARIANT pin — a single-element slice returns `true` for
594    /// exactly the checkpoint it gates on, `false` for every other
595    /// variant. Sweep the `ALL × ALL` cross so a regression that
596    /// (a) hard-coded the arm to a single kind (silently returning
597    /// true for every populated slice regardless of query kind), or
598    /// (b) matched on [`DependsOn::name`] instead of
599    /// [`DependsOn::must_reach`] fails HERE at the substrate primitive.
600    #[test]
601    fn depends_on_slice_has_must_reach_reads_must_reach_field_per_variant() {
602        for populated in MustReachPhase::ALL {
603            let slice = [dep_with(populated)];
604            for query in MustReachPhase::ALL {
605                let expected = query == populated;
606                assert_eq!(
607                    slice.has_must_reach(query),
608                    expected,
609                    "populated={populated:?}: query {query:?} drifted",
610                );
611            }
612        }
613    }
614
615    /// MULTI-ENTRY pin — a slice with multiple entries returns `true`
616    /// for every kind that appears at any position (existential
617    /// quantifier over the slice), `false` for kinds that appear at
618    /// no position. Locks the `any` semantics so a regression that
619    /// collapsed to a `first`-only probe (`slice.first().map_or(false,
620    /// |d| d.must_reach == kind)`) fails here even though the
621    /// single-element per-variant pin above passes.
622    #[test]
623    fn depends_on_slice_has_must_reach_scans_beyond_the_first_position() {
624        // Two entries with distinct checkpoints — the `Attested` entry
625        // sits at index 1, so a `first`-only regression on the
626        // `Running`-at-index-0 arrangement returns the wrong answer for
627        // an `Attested` query.
628        let slice = [
629            dep_with(MustReachPhase::Running),
630            dep_with(MustReachPhase::Attested),
631        ];
632        for present in MustReachPhase::ALL {
633            assert!(
634                slice.has_must_reach(present),
635                "kind at any position must resolve true: {present:?}",
636            );
637        }
638        // Single-checkpoint slice — the OTHER variant must resolve
639        // false. Pins the negative arm of the existential quantifier so
640        // a regression that widened the probe (e.g. `_ => true` catchall)
641        // fails here.
642        let running_only = [
643            dep_with(MustReachPhase::Running),
644            dep_with(MustReachPhase::Running),
645        ];
646        assert!(
647            !running_only.has_must_reach(MustReachPhase::Attested),
648            "kind absent from every position must resolve false",
649        );
650    }
651}