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}