tatara_process/signal.rs
1//! First-class CRD signals — Unix semantics over Kubernetes.
2//!
3//! Signals are delivered via annotation (`tatara.pleme.io/signal=SIGHUP`) or
4//! via the MCP `signal_process` tool; the reconciler consumes them,
5//! enqueues on `status.signalQueue`, and drains in phase order.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::phase::ProcessPhase;
11
12/// The first-class signals the controller honors.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum ProcessSignal {
16 /// Reconfigure — re-enter Execing without termination.
17 /// Fires on spec change, drift detection, or manual invocation.
18 Sighup,
19 /// Graceful terminate — finalizer path with children draining first.
20 Sigterm,
21 /// Force terminate — `grace_period_seconds: 0` on all owned resources.
22 Sigkill,
23 /// Force re-attestation without spec change — recomputes three-pillar hash.
24 Sigusr1,
25 /// Force remediation — invokes kensa remediation hooks.
26 Sigusr2,
27 /// Pause reconciliation — fixed-point driver is suspended.
28 Sigstop,
29 /// Resume reconciliation after SIGSTOP.
30 Sigcont,
31}
32
33impl ProcessSignal {
34 /// The closed set of signals — single source of truth that
35 /// drives `as_str` / `short_str` / `FromStr` so adding a variant
36 /// updates every projection at once (and the
37 /// `short_str_strips_sig_prefix` + `all_signals_roundtrip_*`
38 /// tests pin the bridge).
39 pub const ALL: [Self; 7] = [
40 Self::Sighup,
41 Self::Sigterm,
42 Self::Sigkill,
43 Self::Sigusr1,
44 Self::Sigusr2,
45 Self::Sigstop,
46 Self::Sigcont,
47 ];
48
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Sighup => "SIGHUP",
52 Self::Sigterm => "SIGTERM",
53 Self::Sigkill => "SIGKILL",
54 Self::Sigusr1 => "SIGUSR1",
55 Self::Sigusr2 => "SIGUSR2",
56 Self::Sigstop => "SIGSTOP",
57 Self::Sigcont => "SIGCONT",
58 }
59 }
60
61 /// The short alias accepted by `FromStr` — the canonical
62 /// `as_str()` form with the leading `"SIG"` stripped (`"HUP"`,
63 /// `"TERM"`, …). The `short_str_strips_sig_prefix` test asserts
64 /// this contract structurally so the two projections cannot
65 /// drift.
66 pub const fn short_str(self) -> &'static str {
67 match self {
68 Self::Sighup => "HUP",
69 Self::Sigterm => "TERM",
70 Self::Sigkill => "KILL",
71 Self::Sigusr1 => "USR1",
72 Self::Sigusr2 => "USR2",
73 Self::Sigstop => "STOP",
74 Self::Sigcont => "CONT",
75 }
76 }
77}
78
79impl std::fmt::Display for ProcessSignal {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.write_str(self.as_str())
82 }
83}
84
85impl std::str::FromStr for ProcessSignal {
86 type Err = UnknownSignal;
87 fn from_str(s: &str) -> Result<Self, Self::Err> {
88 let upper = s.to_ascii_uppercase();
89 for sig in Self::ALL {
90 if upper == sig.as_str() || upper == sig.short_str() {
91 return Ok(sig);
92 }
93 }
94 Err(UnknownSignal(upper))
95 }
96}
97
98#[derive(Debug, thiserror::Error)]
99#[error("unknown signal: {0}")]
100pub struct UnknownSignal(pub String);
101
102/// How a Process handles SIGHUP.
103///
104/// Sibling closed-set lifts on the same `ProcessSpec` axis:
105/// [`ProcessSignal::ALL`] (parser triad), [`crate::phase::ProcessPhase::ALL`]
106/// (target codomain), [`crate::spec::MustReachPhase::ALL`] (typed subset of
107/// ProcessPhase).
108#[derive(
109 Clone,
110 Copy,
111 Debug,
112 PartialEq,
113 Eq,
114 Hash,
115 Serialize,
116 Deserialize,
117 JsonSchema,
118 Default,
119 tatara_closed_set::DeriveClosedSet,
120)]
121#[serde(rename_all = "PascalCase")]
122#[closed_set(via = "as_str", generate_unknown, display)]
123pub enum SighupStrategy {
124 /// Running → Reconverging → Execing without tearing down resources.
125 #[default]
126 Reconverge,
127 /// Running → Exiting → Reaped → Pending (full respawn).
128 Restart,
129 /// Ignore the signal.
130 Noop,
131}
132
133impl SighupStrategy {
134 /// The closed set of SIGHUP strategies — single source of truth that
135 /// drives the `as_str` / Display / `FromStr` triad and the typed
136 /// `sighup_target` projection. Adding a fourth strategy (e.g. a
137 /// future `Suspend` that maps SIGHUP to `SignalEffect::Suspend`)
138 /// lands at one `ALL` entry, one `as_str` arm, and one
139 /// `sighup_target` arm — exhaustively checked by the compiler
140 /// (the `[Self; 3]` array literal forces the arity).
141 pub const ALL: [Self; 3] = [Self::Reconverge, Self::Restart, Self::Noop];
142
143 /// Canonical PascalCase wire-format projection — matches the serde
144 /// `rename_all = "PascalCase"` output verbatim. Used by Display
145 /// (single source of truth), by `FromStr` to identify the variant
146 /// from its annotation / status-field representation, and by
147 /// operator-facing diagnostic strings without re-serializing
148 /// through `serde_json`. Pinned by
149 /// `sighup_strategy_as_str_matches_serde`.
150 pub const fn as_str(self) -> &'static str {
151 match self {
152 Self::Reconverge => "Reconverge",
153 Self::Restart => "Restart",
154 Self::Noop => "Noop",
155 }
156 }
157
158 /// Typed projection: when this Process receives SIGHUP while in a
159 /// `is_running()` phase, which `ProcessPhase` does the reconciler
160 /// transition into? `None` means SIGHUP is a no-op (the `Noop`
161 /// strategy). The phase guard lives at the call site (the strategy
162 /// itself doesn't know about phase), so the codomain is purely a
163 /// function of the strategy variant.
164 ///
165 /// Used by `tatara_reconciler::signals::apply` to lift the three
166 /// SIGHUP arms (Reconverge / Restart / Noop) into one
167 /// projection-driven arm. A future variant lands at one
168 /// `sighup_target` arm; `apply` doesn't change.
169 ///
170 /// Pinned by `sighup_target_truth_table` (per-variant codomain)
171 /// AND by `sighup_target_projects_only_to_legal_sighup_transitions`
172 /// (every `Some(target)` must be reachable from `Running` /
173 /// `Attested` via `ProcessPhase::can_transition_to`).
174 pub const fn sighup_target(self) -> Option<ProcessPhase> {
175 match self {
176 Self::Reconverge => Some(ProcessPhase::Reconverging),
177 Self::Restart => Some(ProcessPhase::Exiting),
178 Self::Noop => None,
179 }
180 }
181
182 /// Closed-set-driven presence probe on the derived
183 /// [`Self::sighup_target`] projection — `true` iff this strategy's
184 /// SIGHUP-target [`ProcessPhase`] (as read through
185 /// [`Self::sighup_target`]) equals `phase`.
186 ///
187 /// # NEW representation-kind corner — required-parent × derived-Option-child
188 ///
189 /// The workspace-wide closed-set-driven presence-probe algebra
190 /// grows a new corner here: the child projection returns an
191 /// `Option<K>` (not a raw `K`), so the probe body reads
192 /// `self.sighup_target() == Some(phase)` rather than the
193 /// stored-scalar `self.<field> == kind` shape the sibling scalar-
194 /// carrier probes ([`crate::spec::SignalPolicy::has_sighup_strategy`],
195 /// [`crate::routing::RoutingSpec::has_form`],
196 /// [`crate::classification::Classification::has_calm`], …) publish.
197 /// The [`Self::Noop`] arm's `None` projection answers `false` for
198 /// every `phase` query — the operator's `:requires
199 /// (sighup-target-<phase>)` tag reads "does this Process's SIGHUP
200 /// strategy transition INTO that phase," and `Noop` transitions
201 /// into no phase at all, so the tag never matches on `Noop`.
202 ///
203 /// # Semantics — DERIVED sighup_target, not raw variant equality
204 ///
205 /// `has_target(ProcessPhase::Reconverging)` returns `true` iff
206 /// `self == SighupStrategy::Reconverge` (via
207 /// [`Self::sighup_target`] = `Some(Reconverging)`);
208 /// `has_target(ProcessPhase::Exiting)` returns `true` iff
209 /// `self == SighupStrategy::Restart`. The two probes coexist with
210 /// the sibling stored-scalar `has_sighup_strategy` because they
211 /// answer distinct operator questions: `has_sighup_strategy` asks
212 /// "does this policy CARRY this strategy literal" (raw
213 /// discriminator equality), while `has_target` asks "would SIGHUP
214 /// TRANSITION this Process into that phase" (compound
215 /// `sighup_target` projection). A future
216 /// [`SighupStrategy`] variant that projects to a phase already
217 /// reachable from an existing variant (a hypothetical `Refresh`
218 /// mapping to `Reconverging`) would satisfy
219 /// `has_target(Reconverging)` alongside `Reconverge` — distinct
220 /// stored variants, same derived target — while
221 /// `has_sighup_strategy(Reconverge)` stays keyed strictly on the
222 /// stored variant.
223 ///
224 /// # Compounding
225 ///
226 /// The point-domain require-tag surface in
227 /// `tatara-reconciler::bin::tatara-check` composes this primitive
228 /// with the closed-set [`ProcessPhase`]'s autoderived `FromStr`
229 /// through the `strip_and_classify_prefixed_kind` substrate to
230 /// publish a `sighup-target-<phase>` prefix family byte-for-byte
231 /// symmetrical with the sibling `sighup-<kind>` family that
232 /// composes through [`crate::spec::SignalPolicy::has_sighup_strategy`].
233 /// A future [`SighupStrategy`] variant reaches this probe through
234 /// ONE `ALL` entry + one `as_str` arm + one `sighup_target` arm on
235 /// the closed set — the require-tag classifier picks the new
236 /// projection up mechanically without further per-consumer edits.
237 ///
238 /// A future [`ProcessPhase`] variant paired with a future
239 /// [`SighupStrategy`] variant that targets it lands as a fresh
240 /// `sighup_target` arm on this closed set — the probe body stays
241 /// unchanged, and the ONE match inside [`Self::sighup_target`]
242 /// serves as the arity gate that a compiler catches at the
243 /// exhaustiveness check.
244 ///
245 /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
246 /// preserves proofs; the derived-Option-child presence-probe body
247 /// lives at ONE substrate site so every downstream (the future
248 /// `sighup-target-<phase>` require-tag family in tatara-check,
249 /// closed-set audit dispatchers walking [`ProcessPhase::ALL`],
250 /// future variant additions on either closed set) binds through
251 /// the SAME `has_target(phase)` shape rather than restating the
252 /// `self.sighup_target() == Some(phase)` chain at each callsite.
253 /// THEORY.md §VI.1 — generation over composition; the closed-set
254 /// match in [`Self::sighup_target`] is the ONE per-variant edit
255 /// site a future strategy variant reaches through.
256 #[must_use]
257 pub fn has_target(self, phase: ProcessPhase) -> bool {
258 self.sighup_target() == Some(phase)
259 }
260}
261
262// `impl FromStr for SighupStrategy` +
263// `impl tatara_lisp::ClosedSet for SighupStrategy` +
264// `impl std::fmt::Display for SighupStrategy` +
265// `pub struct UnknownSighupStrategy(pub String)` are all generated
266// by `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
267// "as_str", generate_unknown, display)]` on the enum declaration
268// above. `label` delegates to the inherent
269// `SighupStrategy::as_str` — the PascalCase wire-vocabulary
270// projection stays load-bearing (matches the serde
271// `rename_all = "PascalCase"` projection verbatim), while generic
272// `T: ClosedSet` consumers reach the STABLE workspace-wide name
273// (`label`). The auto-derived carrier label "sighup strategy"
274// matches the prior hand-rolled `#[error("unknown sighup strategy:
275// {0}")]` annotation byte-for-byte.
276//
277// [`ProcessSignal`] is NOT a `ClosedSet` implementor — its
278// `FromStr` keys on a compound projection (`as_str` || `short_str`)
279// with case-insensitive uppercase normalization rather than a
280// single canonical label, the same exemption pattern as
281// [`tatara_lisp::CompilerSpecIoStage`]'s compound `(operation,
282// label)` key. Only [`SighupStrategy`] (single PascalCase label, no
283// normalization) plugs into the derive here.
284
285#[cfg(test)]
286mod tests {
287 use super::{ProcessSignal, SighupStrategy, UnknownSighupStrategy};
288 use crate::phase::ProcessPhase;
289 use std::str::FromStr;
290
291 #[test]
292 fn all_signals_roundtrip_canonical() {
293 for sig in ProcessSignal::ALL {
294 assert_eq!(ProcessSignal::from_str(sig.as_str()).unwrap(), sig);
295 }
296 }
297
298 #[test]
299 fn all_signals_roundtrip_short() {
300 for sig in ProcessSignal::ALL {
301 assert_eq!(ProcessSignal::from_str(sig.short_str()).unwrap(), sig);
302 }
303 }
304
305 /// The structural contract that lets `FromStr` parse off two
306 /// projections without drift: `short_str()` is exactly
307 /// `as_str()` minus the leading `"SIG"`. Any new variant whose
308 /// canonical form doesn't follow the `SIG*` convention has to
309 /// either rename or override this test deliberately.
310 #[test]
311 fn short_str_strips_sig_prefix() {
312 for sig in ProcessSignal::ALL {
313 assert_eq!(sig.as_str().strip_prefix("SIG"), Some(sig.short_str()));
314 }
315 }
316
317 /// `ALL` is the source of truth for the parser table — pin its
318 /// closure so a variant added without an `ALL` entry fails here
319 /// (via the uniqueness check) before drifting `FromStr`.
320 #[test]
321 fn all_is_unique_and_complete() {
322 let mut seen = std::collections::HashSet::new();
323 for sig in ProcessSignal::ALL {
324 assert!(seen.insert(sig), "duplicate variant in ALL: {sig:?}");
325 }
326 // The arity is asserted by the array type itself (`[Self; 7]`),
327 // but the uniqueness check above + the const-array length is
328 // what makes ALL a closed set rather than just a list.
329 assert_eq!(seen.len(), ProcessSignal::ALL.len());
330 }
331
332 #[test]
333 fn lowercase_short_form_accepted() {
334 assert_eq!(
335 ProcessSignal::from_str("hup").unwrap(),
336 ProcessSignal::Sighup
337 );
338 assert_eq!(
339 ProcessSignal::from_str("term").unwrap(),
340 ProcessSignal::Sigterm
341 );
342 }
343
344 #[test]
345 fn unknown_errors() {
346 let err = ProcessSignal::from_str("sigfoo").unwrap_err();
347 // The error carries the uppercased input — preserves the
348 // pre-refactor contract that `UnknownSignal` echoes the
349 // normalized form, not the operator's casing.
350 assert_eq!(err.0, "SIGFOO");
351 }
352
353 // ── closed-set algebra for SighupStrategy (ALL × as_str × FromStr ×
354 // sighup_target) ────────────────────────────────────────────────
355
356 /// Structural well-formedness of [`SighupStrategy`] as a
357 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
358 /// testkit lift that pins all three structural invariants (`ALL`
359 /// is non-empty, every variant round-trips through `label ↔
360 /// parse_label`, labels are pairwise distinct, `""` is outside the
361 /// closed set) at ONE call site. Replaces the hand-derived
362 /// `sighup_strategy_all_is_unique_and_complete` +
363 /// `sighup_strategy_roundtrip_via_as_str` + the empty-input arm of
364 /// `unknown_sighup_strategy_errors`. `FromStr` delegates to
365 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
366 /// exercises the same code path the reconciler hits when parsing a
367 /// CRD `enum:`-validated value back to the typed strategy.
368 #[test]
369 fn sighup_strategy_is_well_formed_closed_set() {
370 tatara_closed_set::assert_closed_set_well_formed::<SighupStrategy>();
371 }
372
373 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
374 /// output verbatim for every variant. A future variant rename (or
375 /// an `as_str` arm typo) lands here at one site, not in a CRD
376 /// `enum:` enumeration that quietly drifted away from the typed
377 /// surface.
378 #[test]
379 fn sighup_strategy_as_str_matches_serde() {
380 crate::tagged_union::assert_label_matches_serde_serialization::<SighupStrategy>();
381 }
382
383 /// The Display impl IS `as_str` — pinning this lets future callers
384 /// reach for either projection without drift.
385 #[test]
386 fn sighup_strategy_display_matches_as_str() {
387 crate::tagged_union::assert_display_matches_label::<SighupStrategy>();
388 }
389
390 /// `FromStr` rejects strings outside the canonical projection
391 /// (lowercased / typo / unrelated) — and the error echoes the
392 /// input verbatim so the operator-facing diagnostic carries the
393 /// offending value, not a normalized form. The empty-input arm is
394 /// pinned by [`sighup_strategy_is_well_formed_closed_set`] via the
395 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
396 /// verbatim-echo contract on the [`UnknownSighupStrategy`]
397 /// newtype, which the trait's `make_unknown` can't see.
398 #[test]
399 fn unknown_sighup_strategy_errors() {
400 for bad in ["reconverge", "RESTART", "Suspend", "noop "] {
401 let err = SighupStrategy::from_str(bad).unwrap_err();
402 let UnknownSighupStrategy(payload) = &err;
403 assert_eq!(payload, bad, "error payload should echo input verbatim");
404 }
405 }
406
407 /// PROJECTION TRUTH TABLE: pin the per-variant codomain of
408 /// `sighup_target`. A future variant addition lands here at one
409 /// arm — and the compiler's closed-set match in `sighup_target`
410 /// catches the missing arm before this test runs.
411 #[test]
412 fn sighup_target_truth_table() {
413 assert_eq!(
414 SighupStrategy::Reconverge.sighup_target(),
415 Some(ProcessPhase::Reconverging)
416 );
417 assert_eq!(
418 SighupStrategy::Restart.sighup_target(),
419 Some(ProcessPhase::Exiting)
420 );
421 assert_eq!(SighupStrategy::Noop.sighup_target(), None);
422 }
423
424 /// SEMANTIC SUBSET CONTRACT: every `Some(target)` produced by
425 /// `sighup_target` must be reachable from a `is_running()` phase
426 /// (`Running` or `Attested`) via `ProcessPhase::can_transition_to`.
427 /// This pins the contract `tatara_reconciler::signals::apply`
428 /// relies on: the typed projection produces only phases the
429 /// reconciler is allowed to transition into from the SIGHUP
430 /// reception sites. A future `SighupStrategy::Refresh` that
431 /// projected to `ProcessPhase::Pending` would FAIL here (Pending
432 /// is not a legal successor of Running/Attested), forcing the
433 /// author to either pick a legal target phase or extend
434 /// `ProcessPhase::can_transition_to` deliberately.
435 #[test]
436 fn sighup_target_projects_only_to_legal_sighup_transitions() {
437 for strat in SighupStrategy::ALL {
438 if let Some(target) = strat.sighup_target() {
439 let reachable_from_running = ProcessPhase::Running.can_transition_to(target);
440 let reachable_from_attested = ProcessPhase::Attested.can_transition_to(target);
441 assert!(
442 reachable_from_running || reachable_from_attested,
443 "{strat:?}.sighup_target() = {target:?} must be reachable from \
444 Running or Attested via can_transition_to",
445 );
446 }
447 }
448 }
449
450 /// PROJECTION INJECTIVITY: distinct variants that produce `Some`
451 /// project to distinct `ProcessPhase`s. Pairing this with the
452 /// reachability contract above forces a future SIGHUP strategy
453 /// variant to land on a fresh legal SIGHUP-target phase (or to
454 /// project to `None` and be a deliberate no-op).
455 #[test]
456 fn sighup_target_projection_is_injective() {
457 let mut seen = std::collections::HashSet::new();
458 for strat in SighupStrategy::ALL {
459 if let Some(target) = strat.sighup_target() {
460 assert!(
461 seen.insert(target),
462 "two variants project to the same ProcessPhase: {target:?}",
463 );
464 }
465 }
466 }
467
468 // ── derived-Option-child presence probe on SighupStrategy ×
469 // ProcessPhase ──────────────────────────────────────────────────
470 //
471 // Fail-before-pass-after granularity: [`SighupStrategy::has_target`]
472 // did not exist before this commit — every consumer of the
473 // `(SighupStrategy, ProcessPhase) -> bool` derived-Option-child probe
474 // shape restated the `strat.sighup_target() == Some(phase)` chain at
475 // its own callsite. Post-lift the shape lives at ONE substrate owner
476 // and every downstream (the `sighup-target-<phase>` require-tag
477 // family in `tatara-check`, future audit dispatchers walking
478 // [`ProcessPhase::ALL`] against SIGHUP transition semantics, any
479 // future closed-set-discriminator projection whose codomain is
480 // `Option<K>`) binds through the SAME `has_target(phase)` shape.
481
482 /// DIAGONAL — for every [`SighupStrategy`] variant, `has_target`
483 /// returns `true` on the phase [`SighupStrategy::sighup_target`]
484 /// projects to (via `Some`) AND `false` on every other phase in
485 /// [`ProcessPhase::ALL`]. Sweep the [`SighupStrategy::ALL`] ×
486 /// [`ProcessPhase::ALL`] cross so a regression that hard-coded the
487 /// arm to a single variant (silently returning `true` on every
488 /// populated strategy regardless of query phase) or wired the
489 /// projection to a fixed unrelated field fails HERE at the
490 /// substrate primitive before landing at the operator-facing
491 /// checks.lisp surface. The `Noop` arm's `None` projection is
492 /// separately pinned by
493 /// [`sighup_strategy_has_target_returns_false_on_noop_for_every_phase`].
494 #[test]
495 fn sighup_strategy_has_target_returns_true_iff_projection_matches() {
496 for strat in SighupStrategy::ALL {
497 let expected_target = strat.sighup_target();
498 for phase in ProcessPhase::ALL {
499 let expected = expected_target == Some(phase);
500 assert_eq!(
501 strat.has_target(phase),
502 expected,
503 "strategy={strat:?}: query phase={phase:?} classification drifted \
504 from sighup_target() projection {expected_target:?}",
505 );
506 }
507 }
508 }
509
510 /// NONE-ARM PIN — [`SighupStrategy::Noop`] projects to `None`
511 /// through [`SighupStrategy::sighup_target`], so `has_target`
512 /// returns `false` for every phase in [`ProcessPhase::ALL`].
513 /// Distinct from the sibling stored-scalar
514 /// [`crate::spec::SignalPolicy::has_sighup_strategy`], which
515 /// returns `true` on the [`SighupStrategy::Noop`] variant when
516 /// queried on `SighupStrategy::Noop` itself — pins the
517 /// derived-Option-child vs stored-scalar semantic split at ONE
518 /// narrow substrate site so a regression that projected `Noop`
519 /// onto a "target Zombie" or "target Pending" arm fails HERE.
520 #[test]
521 fn sighup_strategy_has_target_returns_false_on_noop_for_every_phase() {
522 assert_eq!(SighupStrategy::Noop.sighup_target(), None);
523 for phase in ProcessPhase::ALL {
524 assert!(
525 !SighupStrategy::Noop.has_target(phase),
526 "Noop must return false for phase {phase:?}",
527 );
528 }
529 }
530}