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}