1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! `ProcessSpec` sub-structures — IdentitySpec, DependsOn, SignalPolicy.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::phase::ProcessPhase;
use crate::signal::SighupStrategy;
/// Identity configuration for a Process.
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct IdentitySpec {
/// Parent PID path (None for init/PID 1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
/// Human name override — if set, used verbatim instead of the content hash.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_override: Option<String>,
}
/// Dependency edge — constrains this Process to wait for another to reach a phase.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DependsOn {
/// Target Process `metadata.name`.
pub name: String,
/// Target Process namespace. Defaults to this Process's namespace.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
/// Minimum phase the target must reach before we proceed past Forking.
#[serde(default)]
pub must_reach: MustReachPhase,
}
/// Allowed "must reach" phases for a dependency — restricted to the
/// useful gating checkpoints `Running` (alive + boundary preconditions
/// held) and `Attested` (alive + boundary postconditions held + three-
/// pillar attestation written). Authoring a `DependsOn { must_reach:
/// Forking }` is meaningless; the closed set rules it out at the type
/// level.
///
/// Sibling closed-set lifts on the same `ProcessSpec` axis:
/// [`crate::lifetime::LifetimeKind::ALL`],
/// [`crate::lifetime::TeardownPolicy::ALL`],
/// [`crate::boundary::ConditionKind::ALL`],
/// [`crate::phase::ProcessPhase::ALL`],
/// [`crate::signal::ProcessSignal::ALL`].
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
Default,
tatara_closed_set::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", display, generate_unknown = "must-reach phase")]
pub enum MustReachPhase {
Running,
#[default]
Attested,
}
impl MustReachPhase {
/// The closed set of must-reach phases — single source of truth that
/// drives the `as_str` / Display / `FromStr` triad and the typed
/// `as_process_phase` projection. Adding a third variant (e.g. a
/// future `Released` checkpoint that waits for the target Process to
/// have exited cleanly) lands at one `ALL` entry, one `as_str` arm,
/// and one `as_process_phase` arm — exhaustively checked by the
/// compiler (the `[Self; 2]` array literal forces the arity).
pub const ALL: [Self; 2] = [Self::Running, Self::Attested];
/// Canonical PascalCase wire-format projection — matches the serde
/// `rename_all = "PascalCase"` output verbatim AND the canonical
/// `ProcessPhase::as_str()` projection on the phase this variant
/// gates against. Used by Display (single source of truth), by
/// `FromStr` to identify the variant from its annotation / status-
/// field representation, and by operator-facing diagnostic strings
/// (`tatara-reconciler::boundary::check_depends_on` stamps the
/// required phase via `Display` rather than reaching for `{:?}`
/// Debug formatting). Pinned by `must_reach_phase_as_str_matches_serde`
/// AND by `must_reach_phase_as_str_matches_process_phase_as_str` so
/// a rename on either side surfaces at one site.
pub const fn as_str(self) -> &'static str {
match self {
Self::Running => "Running",
Self::Attested => "Attested",
}
}
/// Typed projection into the canonical `ProcessPhase` this variant
/// gates against. The `From<MustReachPhase> for ProcessPhase` impl
/// delegates here so callers reach for whichever surface fits (the
/// `From` for `into()` flows, this `const fn` for const contexts).
/// Pinned by `must_reach_phase_from_delegates_to_as_process_phase`.
pub const fn as_process_phase(self) -> ProcessPhase {
match self {
Self::Running => ProcessPhase::Running,
Self::Attested => ProcessPhase::Attested,
}
}
}
// `impl FromStr for MustReachPhase` +
// `impl tatara_lisp::ClosedSet for MustReachPhase` +
// `impl fmt::Display for MustReachPhase` +
// `pub struct UnknownMustReachPhase(pub String)` are all generated
// by `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
// "as_str", display, generate_unknown = "must-reach phase")]` on
// the enum declaration above. `label` delegates to the inherent
// `MustReachPhase::as_str` — the PascalCase wire-vocabulary
// projection stays load-bearing (matches the serde rename AND the
// canonical `ProcessPhase::as_str` of the phase this variant gates
// against, pinned by
// `must_reach_phase_as_str_matches_process_phase_as_str`), while
// generic `T: ClosedSet` consumers reach the STABLE workspace-wide
// name (`label`). The explicit `generate_unknown = "must-reach
// phase"` label carries the hyphenated wording that the
// auto-derived `pascal_to_spaced_lowercase("MustReachPhase")` →
// "must reach phase" projection cannot produce — the prior
// hand-rolled `#[error("unknown must-reach phase: {0}")]`
// annotation kept the hyphen, and the explicit attribute preserves
// it through the lift. Symmetric to every other
// `#[derive(DeriveClosedSet)]` implementor across the crate.
impl From<MustReachPhase> for ProcessPhase {
fn from(v: MustReachPhase) -> Self {
v.as_process_phase()
}
}
/// Signal policy — how the Process responds to signals.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SignalPolicy {
/// Grace before escalating SIGTERM → SIGKILL.
#[serde(default = "crate::serde_defaults::default_sigterm_grace_seconds")]
pub sigterm_grace_seconds: u32,
/// Permit force-reap via SIGKILL (default: allow).
#[serde(default = "crate::serde_defaults::default_true")]
pub sigkill_force: bool,
/// How SIGHUP is handled.
#[serde(default)]
pub sighup_strategy: SighupStrategy,
/// Start suspended — requires SIGCONT to transition past Forking.
#[serde(default)]
pub start_suspended: bool,
}
impl Default for SignalPolicy {
fn default() -> Self {
Self {
sigterm_grace_seconds: crate::serde_defaults::default_sigterm_grace_seconds(),
sigkill_force: true,
sighup_strategy: SighupStrategy::default(),
start_suspended: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn must_reach_default_is_attested() {
assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
}
#[test]
fn signal_policy_defaults() {
let p = SignalPolicy::default();
assert_eq!(p.sigterm_grace_seconds, 480);
assert!(p.sigkill_force);
assert!(!p.start_suspended);
}
// ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
// as_process_phase) ──────────────────────────────────────────────
/// Structural well-formedness of [`MustReachPhase`] as a
/// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
/// testkit lift that pins all three structural invariants (`ALL`
/// is non-empty, every variant round-trips through `label ↔
/// parse_label`, labels are pairwise distinct, `""` is outside the
/// closed set) at ONE call site. Replaces the hand-derived
/// `must_reach_phase_all_is_unique_and_complete` +
/// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
/// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
/// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
/// exercises the same code path the reconciler hits when parsing
/// a CRD `enum:`-validated value back to the typed checkpoint.
#[test]
fn must_reach_phase_is_well_formed_closed_set() {
tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
}
/// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
/// output verbatim for every variant. A future variant rename (or
/// an `as_str` arm typo) lands here at one site.
#[test]
fn must_reach_phase_as_str_matches_serde() {
crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
}
/// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
/// matches the canonical `ProcessPhase::as_str()` of the phase it
/// projects to. The two enums share the PascalCase wire format
/// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
/// safe gating checkpoints; a rename on either side (a phase
/// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
/// surfaces here at one site, not buried in a reconciler diagnostic
/// that quietly drifted away from the typed-phase surface.
#[test]
fn must_reach_phase_as_str_matches_process_phase_as_str() {
for kind in MustReachPhase::ALL {
assert_eq!(
kind.as_str(),
kind.as_process_phase().as_str(),
"MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
);
}
}
/// The Display impl IS `as_str` — pinning this lets future callers
/// reach for either projection without drift. If a reviewer
/// accidentally re-introduces an inline match in Display, this test
/// would fail the moment a variant rename touches one site but not
/// the other.
#[test]
fn must_reach_phase_display_matches_as_str() {
crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
}
/// `FromStr` rejects strings that aren't in the canonical
/// projection — lowercased / typo / non-checkpoint phase names —
/// and the error echoes the input verbatim so the operator-facing
/// diagnostic carries the offending value, not a normalized form.
/// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
/// (which are legal `ProcessPhase`s but NOT valid
/// `MustReachPhase` checkpoints) MUST fail to parse — that's the
/// whole point of the closed subset. The empty-input arm is
/// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
/// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
/// verbatim-echo contract on the [`UnknownMustReachPhase`]
/// newtype, which the trait's `make_unknown` can't see, AND the
/// closed-subset contract (non-checkpoint phases reject) the
/// trait's structural surface can't express.
#[test]
fn unknown_must_reach_phase_errors() {
use std::str::FromStr;
for bad in [
"running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
] {
let err = MustReachPhase::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
/// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
/// impl agrees with the typed `as_process_phase()` projection it
/// delegates to, for every variant. A regression that re-introduces
/// an inline match in the `From` impl fails here the moment
/// `as_process_phase` is the source of truth. Pairs with the
/// `as_str` cross-crate test above — together they pin that the
/// projection's value AND wire-format are coherent.
#[test]
fn must_reach_phase_from_delegates_to_as_process_phase() {
for kind in MustReachPhase::ALL {
let via_from: ProcessPhase = kind.into();
assert_eq!(
via_from,
kind.as_process_phase(),
"From<MustReachPhase> drift for {kind:?}",
);
}
}
/// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
/// `ProcessPhase` that is `is_running()` — i.e. one of the live
/// gating checkpoints (`Running` or `Attested`). This pins the
/// closed subset's invariant at the type level: a future
/// `MustReachPhase::Released` (e.g. wait for the target to reach
/// `Reaped`) would FAIL this test, forcing the author to either
/// rename the predicate (`is_running` is wrong for that case) or
/// reconsider whether `MustReachPhase` is the right surface (it
/// shouldn't be — `Released` belongs on a separate "wait for
/// terminal-reached gate" closed set). The compiler enforces
/// closure-on-arity; this test enforces closure-on-semantics.
#[test]
fn must_reach_phase_projects_only_to_live_checkpoints() {
for kind in MustReachPhase::ALL {
let p = kind.as_process_phase();
assert!(
p.is_running(),
"{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
);
}
}
/// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
/// to distinct `ProcessPhase` values. Pairing this with the subset
/// contract above forces a future variant addition to land on a
/// fresh live checkpoint — collapsing two `MustReachPhase` variants
/// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
/// silently makes `from` lossy, which `tatara-reconciler::boundary::
/// check_depends_on`'s diagnostic ("need {required}") would
/// quietly degrade.
#[test]
fn must_reach_phase_projection_is_injective() {
let mut seen = std::collections::HashSet::new();
for kind in MustReachPhase::ALL {
let p = kind.as_process_phase();
assert!(
seen.insert(p),
"MustReachPhase projection collision: {kind:?} → {p:?}",
);
}
assert_eq!(seen.len(), MustReachPhase::ALL.len());
}
}