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
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
//! First-class CRD signals — Unix semantics over Kubernetes.
//!
//! Signals are delivered via annotation (`tatara.pleme.io/signal=SIGHUP`) or
//! via the MCP `signal_process` tool; the reconciler consumes them,
//! enqueues on `status.signalQueue`, and drains in phase order.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::phase::ProcessPhase;
/// The first-class signals the controller honors.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProcessSignal {
/// Reconfigure — re-enter Execing without termination.
/// Fires on spec change, drift detection, or manual invocation.
Sighup,
/// Graceful terminate — finalizer path with children draining first.
Sigterm,
/// Force terminate — `grace_period_seconds: 0` on all owned resources.
Sigkill,
/// Force re-attestation without spec change — recomputes three-pillar hash.
Sigusr1,
/// Force remediation — invokes kensa remediation hooks.
Sigusr2,
/// Pause reconciliation — fixed-point driver is suspended.
Sigstop,
/// Resume reconciliation after SIGSTOP.
Sigcont,
}
impl ProcessSignal {
/// The closed set of signals — single source of truth that
/// drives `as_str` / `short_str` / `FromStr` so adding a variant
/// updates every projection at once (and the
/// `short_str_strips_sig_prefix` + `all_signals_roundtrip_*`
/// tests pin the bridge).
pub const ALL: [Self; 7] = [
Self::Sighup,
Self::Sigterm,
Self::Sigkill,
Self::Sigusr1,
Self::Sigusr2,
Self::Sigstop,
Self::Sigcont,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Sighup => "SIGHUP",
Self::Sigterm => "SIGTERM",
Self::Sigkill => "SIGKILL",
Self::Sigusr1 => "SIGUSR1",
Self::Sigusr2 => "SIGUSR2",
Self::Sigstop => "SIGSTOP",
Self::Sigcont => "SIGCONT",
}
}
/// The short alias accepted by `FromStr` — the canonical
/// `as_str()` form with the leading `"SIG"` stripped (`"HUP"`,
/// `"TERM"`, …). The `short_str_strips_sig_prefix` test asserts
/// this contract structurally so the two projections cannot
/// drift.
pub const fn short_str(self) -> &'static str {
match self {
Self::Sighup => "HUP",
Self::Sigterm => "TERM",
Self::Sigkill => "KILL",
Self::Sigusr1 => "USR1",
Self::Sigusr2 => "USR2",
Self::Sigstop => "STOP",
Self::Sigcont => "CONT",
}
}
}
impl std::fmt::Display for ProcessSignal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ProcessSignal {
type Err = UnknownSignal;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let upper = s.to_ascii_uppercase();
for sig in Self::ALL {
if upper == sig.as_str() || upper == sig.short_str() {
return Ok(sig);
}
}
Err(UnknownSignal(upper))
}
}
#[derive(Debug, thiserror::Error)]
#[error("unknown signal: {0}")]
pub struct UnknownSignal(pub String);
/// How a Process handles SIGHUP.
///
/// Sibling closed-set lifts on the same `ProcessSpec` axis:
/// [`ProcessSignal::ALL`] (parser triad), [`crate::phase::ProcessPhase::ALL`]
/// (target codomain), [`crate::spec::MustReachPhase::ALL`] (typed subset of
/// ProcessPhase).
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
Default,
tatara_closed_set::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum SighupStrategy {
/// Running → Reconverging → Execing without tearing down resources.
#[default]
Reconverge,
/// Running → Exiting → Reaped → Pending (full respawn).
Restart,
/// Ignore the signal.
Noop,
}
impl SighupStrategy {
/// The closed set of SIGHUP strategies — single source of truth that
/// drives the `as_str` / Display / `FromStr` triad and the typed
/// `sighup_target` projection. Adding a fourth strategy (e.g. a
/// future `Suspend` that maps SIGHUP to `SignalEffect::Suspend`)
/// lands at one `ALL` entry, one `as_str` arm, and one
/// `sighup_target` arm — exhaustively checked by the compiler
/// (the `[Self; 3]` array literal forces the arity).
pub const ALL: [Self; 3] = [Self::Reconverge, Self::Restart, Self::Noop];
/// Canonical PascalCase wire-format projection — matches the serde
/// `rename_all = "PascalCase"` output verbatim. 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 without re-serializing
/// through `serde_json`. Pinned by
/// `sighup_strategy_as_str_matches_serde`.
pub const fn as_str(self) -> &'static str {
match self {
Self::Reconverge => "Reconverge",
Self::Restart => "Restart",
Self::Noop => "Noop",
}
}
/// Typed projection: when this Process receives SIGHUP while in a
/// `is_running()` phase, which `ProcessPhase` does the reconciler
/// transition into? `None` means SIGHUP is a no-op (the `Noop`
/// strategy). The phase guard lives at the call site (the strategy
/// itself doesn't know about phase), so the codomain is purely a
/// function of the strategy variant.
///
/// Used by `tatara_reconciler::signals::apply` to lift the three
/// SIGHUP arms (Reconverge / Restart / Noop) into one
/// projection-driven arm. A future variant lands at one
/// `sighup_target` arm; `apply` doesn't change.
///
/// Pinned by `sighup_target_truth_table` (per-variant codomain)
/// AND by `sighup_target_projects_only_to_legal_sighup_transitions`
/// (every `Some(target)` must be reachable from `Running` /
/// `Attested` via `ProcessPhase::can_transition_to`).
pub const fn sighup_target(self) -> Option<ProcessPhase> {
match self {
Self::Reconverge => Some(ProcessPhase::Reconverging),
Self::Restart => Some(ProcessPhase::Exiting),
Self::Noop => None,
}
}
}
// `impl FromStr for SighupStrategy` +
// `impl tatara_lisp::ClosedSet for SighupStrategy` +
// `impl std::fmt::Display for SighupStrategy` +
// `pub struct UnknownSighupStrategy(pub String)` are all generated
// by `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
// "as_str", generate_unknown, display)]` on the enum declaration
// above. `label` delegates to the inherent
// `SighupStrategy::as_str` — the PascalCase wire-vocabulary
// projection stays load-bearing (matches the serde
// `rename_all = "PascalCase"` projection verbatim), while generic
// `T: ClosedSet` consumers reach the STABLE workspace-wide name
// (`label`). The auto-derived carrier label "sighup strategy"
// matches the prior hand-rolled `#[error("unknown sighup strategy:
// {0}")]` annotation byte-for-byte.
//
// [`ProcessSignal`] is NOT a `ClosedSet` implementor — its
// `FromStr` keys on a compound projection (`as_str` || `short_str`)
// with case-insensitive uppercase normalization rather than a
// single canonical label, the same exemption pattern as
// [`tatara_lisp::CompilerSpecIoStage`]'s compound `(operation,
// label)` key. Only [`SighupStrategy`] (single PascalCase label, no
// normalization) plugs into the derive here.
#[cfg(test)]
mod tests {
use super::{ProcessSignal, SighupStrategy, UnknownSighupStrategy};
use crate::phase::ProcessPhase;
use std::str::FromStr;
#[test]
fn all_signals_roundtrip_canonical() {
for sig in ProcessSignal::ALL {
assert_eq!(ProcessSignal::from_str(sig.as_str()).unwrap(), sig);
}
}
#[test]
fn all_signals_roundtrip_short() {
for sig in ProcessSignal::ALL {
assert_eq!(ProcessSignal::from_str(sig.short_str()).unwrap(), sig);
}
}
/// The structural contract that lets `FromStr` parse off two
/// projections without drift: `short_str()` is exactly
/// `as_str()` minus the leading `"SIG"`. Any new variant whose
/// canonical form doesn't follow the `SIG*` convention has to
/// either rename or override this test deliberately.
#[test]
fn short_str_strips_sig_prefix() {
for sig in ProcessSignal::ALL {
assert_eq!(sig.as_str().strip_prefix("SIG"), Some(sig.short_str()));
}
}
/// `ALL` is the source of truth for the parser table — pin its
/// closure so a variant added without an `ALL` entry fails here
/// (via the uniqueness check) before drifting `FromStr`.
#[test]
fn all_is_unique_and_complete() {
let mut seen = std::collections::HashSet::new();
for sig in ProcessSignal::ALL {
assert!(seen.insert(sig), "duplicate variant in ALL: {sig:?}");
}
// The arity is asserted by the array type itself (`[Self; 7]`),
// but the uniqueness check above + the const-array length is
// what makes ALL a closed set rather than just a list.
assert_eq!(seen.len(), ProcessSignal::ALL.len());
}
#[test]
fn lowercase_short_form_accepted() {
assert_eq!(
ProcessSignal::from_str("hup").unwrap(),
ProcessSignal::Sighup
);
assert_eq!(
ProcessSignal::from_str("term").unwrap(),
ProcessSignal::Sigterm
);
}
#[test]
fn unknown_errors() {
let err = ProcessSignal::from_str("sigfoo").unwrap_err();
// The error carries the uppercased input — preserves the
// pre-refactor contract that `UnknownSignal` echoes the
// normalized form, not the operator's casing.
assert_eq!(err.0, "SIGFOO");
}
// ── closed-set algebra for SighupStrategy (ALL × as_str × FromStr ×
// sighup_target) ────────────────────────────────────────────────
/// Structural well-formedness of [`SighupStrategy`] 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
/// `sighup_strategy_all_is_unique_and_complete` +
/// `sighup_strategy_roundtrip_via_as_str` + the empty-input arm of
/// `unknown_sighup_strategy_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 strategy.
#[test]
fn sighup_strategy_is_well_formed_closed_set() {
tatara_closed_set::assert_closed_set_well_formed::<SighupStrategy>();
}
/// 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, not in a CRD
/// `enum:` enumeration that quietly drifted away from the typed
/// surface.
#[test]
fn sighup_strategy_as_str_matches_serde() {
crate::tagged_union::assert_label_matches_serde_serialization::<SighupStrategy>();
}
/// The Display impl IS `as_str` — pinning this lets future callers
/// reach for either projection without drift.
#[test]
fn sighup_strategy_display_matches_as_str() {
crate::tagged_union::assert_display_matches_label::<SighupStrategy>();
}
/// `FromStr` rejects strings outside the canonical projection
/// (lowercased / typo / unrelated) — and the error echoes the
/// input verbatim so the operator-facing diagnostic carries the
/// offending value, not a normalized form. The empty-input arm is
/// pinned by [`sighup_strategy_is_well_formed_closed_set`] via the
/// `tatara_lisp::ClosedSet` testkit; the cases here pin the
/// verbatim-echo contract on the [`UnknownSighupStrategy`]
/// newtype, which the trait's `make_unknown` can't see.
#[test]
fn unknown_sighup_strategy_errors() {
for bad in ["reconverge", "RESTART", "Suspend", "noop "] {
let err = SighupStrategy::from_str(bad).unwrap_err();
let UnknownSighupStrategy(payload) = &err;
assert_eq!(payload, bad, "error payload should echo input verbatim");
}
}
/// PROJECTION TRUTH TABLE: pin the per-variant codomain of
/// `sighup_target`. A future variant addition lands here at one
/// arm — and the compiler's closed-set match in `sighup_target`
/// catches the missing arm before this test runs.
#[test]
fn sighup_target_truth_table() {
assert_eq!(
SighupStrategy::Reconverge.sighup_target(),
Some(ProcessPhase::Reconverging)
);
assert_eq!(
SighupStrategy::Restart.sighup_target(),
Some(ProcessPhase::Exiting)
);
assert_eq!(SighupStrategy::Noop.sighup_target(), None);
}
/// SEMANTIC SUBSET CONTRACT: every `Some(target)` produced by
/// `sighup_target` must be reachable from a `is_running()` phase
/// (`Running` or `Attested`) via `ProcessPhase::can_transition_to`.
/// This pins the contract `tatara_reconciler::signals::apply`
/// relies on: the typed projection produces only phases the
/// reconciler is allowed to transition into from the SIGHUP
/// reception sites. A future `SighupStrategy::Refresh` that
/// projected to `ProcessPhase::Pending` would FAIL here (Pending
/// is not a legal successor of Running/Attested), forcing the
/// author to either pick a legal target phase or extend
/// `ProcessPhase::can_transition_to` deliberately.
#[test]
fn sighup_target_projects_only_to_legal_sighup_transitions() {
for strat in SighupStrategy::ALL {
if let Some(target) = strat.sighup_target() {
let reachable_from_running = ProcessPhase::Running.can_transition_to(target);
let reachable_from_attested = ProcessPhase::Attested.can_transition_to(target);
assert!(
reachable_from_running || reachable_from_attested,
"{strat:?}.sighup_target() = {target:?} must be reachable from \
Running or Attested via can_transition_to",
);
}
}
}
/// PROJECTION INJECTIVITY: distinct variants that produce `Some`
/// project to distinct `ProcessPhase`s. Pairing this with the
/// reachability contract above forces a future SIGHUP strategy
/// variant to land on a fresh legal SIGHUP-target phase (or to
/// project to `None` and be a deliberate no-op).
#[test]
fn sighup_target_projection_is_injective() {
let mut seen = std::collections::HashSet::new();
for strat in SighupStrategy::ALL {
if let Some(target) = strat.sighup_target() {
assert!(
seen.insert(target),
"two variants project to the same ProcessPhase: {target:?}",
);
}
}
}
}