Skip to main content

tatara_process/
pool.rs

1//! `EphemeralPool` CRD — a population of warm, pre-attested ephemeral
2//! Processes that get *allocated* to requestors (e.g., a GitHub PR
3//! flow) on demand and *returned* (per a typed policy) when the
4//! requestor releases them.
5//!
6//! Compounding move: the pool is a population manager **over the
7//! existing Process algebra**, not a parallel runtime. A pool member
8//! is just a `Process` with `Lifetime::Permanent` while in the free
9//! list; allocation is "the operator (the pool reconciler) flips
10//! that Process's lifetime slot to Ephemeral with the requestor's
11//! TTL." Zero new compute primitive.
12//!
13//! Topology:
14//!
15//! ```text
16//! EphemeralPool       (this CRD)
17//!   ├── PoolSpec      (desired_size, template (EphemeralSpec), return_policy, selector)
18//!   ├── PoolStatus    (phase, free / allocated / spawning / returning counts, members)
19//!   └── owns N Processes via ownerReferences (one per pool slot)
20//!
21//! EphemeralAllocation (see allocation.rs)
22//!   ├── AllocationSpec (pool_ref, requestor, requested_at, lifetime override)
23//!   └── AllocationStatus (phase, assigned_process_ref, allocated_at, expires_at)
24//! ```
25
26use chrono::{DateTime, Utc};
27use kube::CustomResource;
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30
31use crate::ephemeral::EphemeralSpec;
32
33/// `EphemeralPool` CRD spec — typed pool of warm Processes.
34///
35/// ```yaml
36/// apiVersion: tatara.pleme.io/v1alpha1
37/// kind: EphemeralPool
38/// metadata:
39///   name: attest-pool
40///   namespace: ephemeral-pools
41/// spec:
42///   desiredSize: 3
43///   minSize: 1
44///   maxSize: 5
45///   returnPolicy: Reset
46///   selector:
47///     repos: ["pleme-io/demo-*"]
48///     branches: ["main", "release-*"]
49///     prLabels: ["needs-ephemeral"]
50///   template:
51///     aplicacao:
52///       chartRef: "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
53///       version: "0.5.5"
54///       profile: "all-in-one"
55///       …
56///     ttl: "2h"
57///     teardown: OnAttested
58///     postconditions: [ … ]
59/// ```
60#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)]
61#[kube(
62    group = "tatara.pleme.io",
63    version = "v1alpha1",
64    kind = "EphemeralPool",
65    plural = "ephemeralpools",
66    shortname = "epool",
67    namespaced,
68    status = "PoolStatus",
69    printcolumn = r#"{"name":"Desired","type":"integer","jsonPath":".spec.desiredSize"}"#,
70    printcolumn = r#"{"name":"Ready","type":"integer","jsonPath":".status.readyCount"}"#,
71    printcolumn = r#"{"name":"Allocated","type":"integer","jsonPath":".status.allocatedCount"}"#,
72    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
73    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
74)]
75#[serde(rename_all = "camelCase")]
76pub struct PoolSpec {
77    /// Target number of warm Processes the pool maintains in `Free`
78    /// state (sum of Free + Spawning targets `desired_size`).
79    pub desired_size: u32,
80
81    /// Hard floor on the free count. The reconciler refuses to scale
82    /// below this even on cost-pressure signals. Default = 0.
83    #[serde(default)]
84    pub min_size: u32,
85
86    /// Hard ceiling on total pool members (free + allocated + spawning).
87    /// `0` = no cap. Default = 0.
88    #[serde(default)]
89    pub max_size: u32,
90
91    /// What to do when an allocation releases.
92    #[serde(default)]
93    pub return_policy: ReturnPolicy,
94
95    /// Routing selector — which allocation requests this pool serves.
96    /// The reconciler matches incoming `EphemeralAllocation` CRs
97    /// against this selector (most-specific wins across pools sharing
98    /// a namespace).
99    #[serde(default)]
100    pub selector: PoolSelector,
101
102    /// Template for each pool member — a typed `EphemeralSpec` that
103    /// the reconciler lowers to `ProcessSpec` and instantiates.
104    /// While in the free list each member's lifetime is overridden
105    /// to `Permanent`; allocation flips it back to `Ephemeral` with
106    /// the requestor's TTL.
107    pub template: EphemeralSpec,
108
109    /// How long a pool member may sit in `Free` before the reconciler
110    /// recycles it (humantime). Defends against drift / stale state.
111    /// Default `"24h"`.
112    #[serde(default = "default_free_ttl")]
113    pub free_ttl: String,
114
115    /// Max time the reconciler allows a single allocation to hold a
116    /// member before forcibly returning it (humantime). Hard cap
117    /// independent of the allocation's own TTL. Default `"4h"`.
118    #[serde(default = "default_max_allocation_ttl")]
119    pub max_allocation_ttl: String,
120
121    /// **R5 desired-count loop** — when set non-zero, the pool
122    /// reconciler maintains exactly this many *healthy* (Running or
123    /// Attested) Processes regardless of allocation pressure. Drives
124    /// the "always seeking stability" property: failed members are
125    /// replaced per `replacement_policy`. `0` keeps the legacy
126    /// allocation-driven sizing (desired = floor of free + allocated).
127    ///
128    /// Operator usage: `desired: 5` means "always have 5 of these
129    /// running"; failures auto-replace.
130    #[serde(default)]
131    pub desired: u32,
132
133    /// **R5** — what the pool reconciler does when a member reaches
134    /// `Failed` phase.
135    #[serde(default)]
136    pub replacement_policy: ReplacementPolicy,
137
138    /// **R5** — when true, exactly one healthy member of the pool
139    /// holds the unprefixed-form DNS hostnames declared in
140    /// `template.routing` at any moment. The claim arbiter (see
141    /// `tatara-reconciler::claim`) transfers atomically when the
142    /// holder fails.
143    #[serde(default)]
144    pub stable_name_claim: bool,
145}
146
147/// What the pool reconciler does when a member reaches `Failed`.
148///
149/// Sibling closed-set lifts on the same `tatara-process` axis:
150/// [`crate::compliance::VerificationPhase::ALL`],
151/// [`crate::signal::SighupStrategy::ALL`],
152/// [`crate::spec::MustReachPhase::ALL`],
153/// [`crate::intent::WorkloadKind::ALL`],
154/// [`crate::export::ReportFormat::ALL`],
155/// [`crate::encapsulates::EncapsulationMode::ALL`],
156/// [`crate::export::ExportTrigger::ALL`],
157/// [`crate::lifetime::TeardownPolicy::ALL`],
158/// [`crate::boundary::ConditionKind::ALL`],
159/// [`crate::lifetime::LifetimeKind::ALL`],
160/// [`crate::intent::IntentKind::ALL`],
161/// [`crate::phase::ProcessPhase::ALL`],
162/// [`crate::signal::ProcessSignal::ALL`].
163#[derive(
164    Clone,
165    Copy,
166    Debug,
167    Default,
168    Serialize,
169    Deserialize,
170    JsonSchema,
171    PartialEq,
172    Eq,
173    Hash,
174    tatara_closed_set::DeriveClosedSet,
175)]
176#[serde(rename_all = "PascalCase")]
177#[closed_set(via = "as_str", generate_unknown, display)]
178pub enum ReplacementPolicy {
179    /// **Default** — Failed member is reaped + replaced immediately
180    /// (pool stays at `desired` count). Most production-like.
181    #[default]
182    ReplaceImmediate,
183    /// Failed member stays for inspection; pool runs short until the
184    /// operator manually reaps it. Useful for debugging.
185    HoldFailed,
186    /// Failed member triggers pool-wide pause: `desired` is
187    /// effectively 0 until the operator manually resumes via a
188    /// pool-status patch. Used for "halt on any failure" workflows.
189    PausePool,
190}
191
192impl ReplacementPolicy {
193    /// The closed set of replacement policies — single source of truth
194    /// that drives the `as_str` / Display / `FromStr` triad and the
195    /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
196    /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
197    /// predicate arm per projection — exhaustively checked by the
198    /// compiler (the `[Self; 3]` array literal forces the arity) and by
199    /// the predicate-pair injectivity test below (a new variant must
200    /// land in its own (replaces_failed, pauses_on_failure) bucket or
201    /// the author has to extend the consumer dispatch in
202    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
203    pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
204
205    /// Canonical PascalCase wire-format projection — matches the serde
206    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
207    /// enumeration the pool reconciler stamps on the
208    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
209    /// `replacement_policy_as_str_matches_serde` so a variant rename
210    /// can't drift between the typed surface, the CRD enum, the YAML
211    /// wire format AND the operator-facing diagnostic (the
212    /// `desired.rs` Pause reason composes `policy={policy}` via
213    /// Display, not a hard-coded `"PausePool"` literal that would
214    /// silently rot).
215    pub const fn as_str(self) -> &'static str {
216        match self {
217            Self::ReplaceImmediate => "ReplaceImmediate",
218            Self::HoldFailed => "HoldFailed",
219            Self::PausePool => "PausePool",
220        }
221    }
222
223    /// Should the pool auto-spawn a replacement for a Failed member?
224    /// Closed-set match (not `matches!`) so a future variant triggers
225    /// the compiler's exhaustiveness check at this site rather than
226    /// silently defaulting to `false`. Paired with
227    /// `pauses_on_failure` they form the two-axis projection
228    /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
229    /// pattern-match against — `replaces_failed` true ⇒ emit
230    /// `ReapFailed` per failure; `pauses_on_failure` true with any
231    /// failure ⇒ emit `Pause` and short-circuit. The pair is
232    /// `(true, false) | (false, false) | (false, true)` — pinned
233    /// injective by `replacement_policy_predicate_pair_is_injective`.
234    pub const fn replaces_failed(self) -> bool {
235        match self {
236            Self::ReplaceImmediate => true,
237            Self::HoldFailed | Self::PausePool => false,
238        }
239    }
240
241    /// Should reaching Failed on any member pause the whole pool?
242    /// See `replaces_failed` for the closed-match rationale + the
243    /// predicate-pair contract.
244    pub const fn pauses_on_failure(self) -> bool {
245        match self {
246            Self::PausePool => true,
247            Self::ReplaceImmediate | Self::HoldFailed => false,
248        }
249    }
250}
251
252// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
253// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
254// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
255// declaration above. `label` delegates to the inherent
256// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
257// PascalCase wire-format projection stays load-bearing (matches the
258// serde `rename_all = "PascalCase"` output AND the
259// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
260// emission verbatim) while generic `T: ClosedSet` consumers reach the
261// STABLE workspace-wide name (`label`); Display delegates to the same
262// inherent projection via `#[closed_set(display)]` so the
263// `Pause` reason emitter's `policy={policy}` composition stays
264// pinned on the closed-set algebra rather than on a hand-rolled
265// `fmt::Display` block per implementor.
266
267// `pub struct UnknownReplacementPolicy(pub String)` is generated by
268// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
269// on the enum declaration above. The auto-derived label
270// `"replacement policy"` matches the prior hand-rolled
271// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
272// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
273// [`crate::export::UnknownReportFormat`],
274// [`crate::export::UnknownChannelKind`],
275// [`crate::export::UnknownExportTrigger`],
276// [`crate::lifetime::UnknownTeardownPolicy`],
277// [`crate::boundary::UnknownConditionKind`], and
278// [`crate::phase::UnknownPhase`].
279
280fn default_free_ttl() -> String {
281    "24h".to_string()
282}
283fn default_max_allocation_ttl() -> String {
284    "4h".to_string()
285}
286
287/// `EphemeralPool.status` — observed pool population state.
288#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
289#[serde(rename_all = "camelCase")]
290pub struct PoolStatus {
291    /// Pool lifecycle phase.
292    #[serde(default)]
293    pub phase: PoolPhase,
294
295    /// When the pool entered the current phase.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub phase_since: Option<DateTime<Utc>>,
298
299    /// Number of members currently in `Free` state (ready for allocation).
300    #[serde(default)]
301    pub ready_count: u32,
302
303    /// Number of members currently `Allocated`.
304    #[serde(default)]
305    pub allocated_count: u32,
306
307    /// Number of members currently `Spawning` (not yet Attested).
308    #[serde(default)]
309    pub spawning_count: u32,
310
311    /// Number of members currently `Returning` (reset or replace
312    /// in progress).
313    #[serde(default)]
314    pub returning_count: u32,
315
316    /// Member ledger — one entry per pool slot.
317    #[serde(default)]
318    pub members: Vec<PoolMember>,
319
320    /// Operator-visible message (e.g., "scaled down to floor").
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub message: Option<String>,
323
324    /// Standard Kubernetes Conditions.
325    #[serde(default)]
326    pub conditions: Vec<PoolCondition>,
327}
328
329/// One pool slot's state.
330#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
331#[serde(rename_all = "camelCase")]
332pub struct PoolMember {
333    /// `metadata.name` of the backing Process.
334    pub process_name: String,
335    /// Pool member's current slot state.
336    pub state: MemberState,
337    /// When the member entered the current state.
338    pub entered_state_at: DateTime<Utc>,
339    /// If allocated: the AllocationRef holding this slot.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub allocation_ref: Option<AllocationRef>,
342}
343
344/// Light reference to an `EphemeralAllocation`.
345#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
346#[serde(rename_all = "camelCase")]
347pub struct AllocationRef {
348    pub name: String,
349    pub namespace: String,
350}
351
352/// Per-slot state in the pool's free list.
353///
354/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
355/// (the on-failure policy that the pool reconciler dispatches against
356/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
357/// release-time disposition that transitions an [`Self::Allocated`]
358/// member into [`Self::Returning`] before it either re-enters
359/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
360#[derive(
361    Clone,
362    Copy,
363    Debug,
364    PartialEq,
365    Eq,
366    Hash,
367    Serialize,
368    Deserialize,
369    JsonSchema,
370    tatara_closed_set::DeriveClosedSet,
371)]
372#[serde(rename_all = "PascalCase")]
373#[closed_set(via = "as_str", generate_unknown, display)]
374pub enum MemberState {
375    /// Pool reconciler is creating/converging the backing Process.
376    Spawning,
377    /// Process is `Attested`; ready for allocation.
378    Free,
379    /// Held by an `EphemeralAllocation`.
380    Allocated,
381    /// Return policy is being applied (Reset → reset Job; Replace →
382    /// Process is being torn down and recreated).
383    Returning,
384    /// Permanent failure — the member needs operator attention.
385    Failed,
386}
387
388impl MemberState {
389    /// The closed set of member states — single source of truth that
390    /// drives the `as_str` / Display / `FromStr` triad AND the
391    /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
392    /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
393    /// arm per predicate — exhaustively checked by the compiler (the
394    /// `[Self; 5]` array literal forces the arity) and by the
395    /// per-variant truth-table contract test (a new variant must
396    /// declare its own `(is_failed, counts_toward_supply)` projection
397    /// or the consumer dispatch in
398    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
399    /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
400    /// will silently bucket it into the wrong lifecycle column).
401    pub const ALL: [Self; 5] = [
402        Self::Spawning,
403        Self::Free,
404        Self::Allocated,
405        Self::Returning,
406        Self::Failed,
407    ];
408
409    /// Canonical PascalCase wire-format projection — matches the serde
410    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
411    /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
412    /// `status.members[].state`. Pinned by
413    /// `member_state_as_str_matches_serde` so a variant rename can't
414    /// drift between the typed surface, the CRD enum, the YAML wire
415    /// format AND any future operator-facing diagnostic that composes
416    /// `state={state}` via Display rather than a hard-coded literal
417    /// that would silently rot.
418    pub const fn as_str(self) -> &'static str {
419        match self {
420            Self::Spawning => "Spawning",
421            Self::Free => "Free",
422            Self::Allocated => "Allocated",
423            Self::Returning => "Returning",
424            Self::Failed => "Failed",
425        }
426    }
427
428    /// Is this member in a permanent-failure state — needs operator
429    /// attention? Closed-set match (not `matches!`) so a future variant
430    /// triggers the compiler's exhaustiveness check at this site rather
431    /// than silently defaulting to `false`. Consumed by
432    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
433    /// gate the highest-priority `ReplaceMembers` decision branch — a
434    /// future variant that should also trigger replacement (e.g.
435    /// `MemberState::Quarantined`) flips this predicate at one site
436    /// and inherits the priority-1 dispatch without touching the
437    /// consumer match arm.
438    pub const fn is_failed(self) -> bool {
439        match self {
440            Self::Failed => true,
441            Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
442        }
443    }
444
445    /// Does this member contribute to the pool's *available supply*
446    /// (current ready slots + slots coming online)? Closed-set match so
447    /// a future variant triggers the compiler's exhaustiveness check.
448    /// Consumed by
449    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
450    /// — the `(free + spawning)` supply calc collapses into one
451    /// predicate-driven filter, so a future "warming-up" state
452    /// (`MemberState::Warming` between Spawning and Free) plugs into
453    /// the supply count at one site rather than three. Disjoint with
454    /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
455    /// (a Failed member can never count toward supply; the pool
456    /// reconciler would otherwise double-count failures as available
457    /// capacity).
458    pub const fn counts_toward_supply(self) -> bool {
459        match self {
460            Self::Free | Self::Spawning => true,
461            Self::Allocated | Self::Returning | Self::Failed => false,
462        }
463    }
464}
465
466// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
467// MemberState` + `impl fmt::Display for MemberState` are generated by
468// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
469// above. `label` delegates to the inherent `MemberState::as_str` via
470// `#[closed_set(via = "as_str")]` so the
471// `pool_phase_from_members` supply calc can keep keying on
472// `counts_toward_supply` against the typed variant while a generic
473// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
474// (`label`) without knowing this enum lives in `tatara-process::pool`;
475// Display delegates to the same inherent projection via
476// `#[closed_set(display)]` so the diagnostic emitter's
477// `state={state}` composition stays pinned on the closed-set algebra.
478
479// `pub struct UnknownMemberState(pub String)` is generated by
480// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
481// on the enum declaration above. The auto-derived label `"member state"`
482// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
483// verbatim. Symmetric to [`UnknownReplacementPolicy`],
484// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
485// [`crate::lifetime::UnknownTeardownPolicy`],
486// [`crate::boundary::UnknownConditionKind`], and
487// [`crate::phase::UnknownPhase`].
488
489/// Pool lifecycle phase (observed across the whole pool population).
490///
491/// Sibling closed-set on the same `EphemeralPool` axis as
492/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
493/// over via [`MemberState::counts_toward_supply`]),
494/// [`ReplacementPolicy::ALL`] (on-failure policy) and
495/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
496/// `MemberState`, this closes the pool reconciler's
497/// `(slot-state, pool-phase)` two-tier observation algebra on the
498/// same closed-set discipline as the rest of `tatara-process`.
499#[derive(
500    Clone,
501    Copy,
502    Debug,
503    PartialEq,
504    Eq,
505    Hash,
506    Serialize,
507    Deserialize,
508    JsonSchema,
509    tatara_closed_set::DeriveClosedSet,
510)]
511#[serde(rename_all = "PascalCase")]
512#[closed_set(via = "as_str", generate_unknown, display)]
513pub enum PoolPhase {
514    /// Just admitted; no members yet.
515    Initializing,
516    /// `ready_count == desired_size`.
517    Steady,
518    /// `ready_count + spawning_count < desired_size` and reconciler
519    /// is creating new members.
520    ScalingUp,
521    /// `ready_count > desired_size` and reconciler is reaping excess.
522    ScalingDown,
523    /// `min_size` constraint violated.
524    Degraded,
525    /// Pool is being deleted; reconciler is reaping all members.
526    Draining,
527}
528
529impl Default for PoolPhase {
530    fn default() -> Self {
531        Self::Initializing
532    }
533}
534
535impl PoolPhase {
536    /// The closed set of pool phases — single source of truth that
537    /// drives the `as_str` / Display / `FromStr` triad AND the
538    /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
539    /// variant lands at one `ALL` entry + one `as_str` arm + one arm
540    /// per predicate — exhaustively checked by the compiler (the
541    /// `[Self; 6]` array literal forces the arity) AND by the
542    /// per-variant truth-table contract test (a new variant must
543    /// declare its own `(is_steady, is_terminal)` projection or any
544    /// future status-aggregator surface — `feira pool list
545    /// --healthy`, the operator-facing condition aggregator, the
546    /// desired-loop heartbeat short-circuit — will silently bucket
547    /// it into the wrong lifecycle column).
548    pub const ALL: [Self; 6] = [
549        Self::Initializing,
550        Self::Steady,
551        Self::ScalingUp,
552        Self::ScalingDown,
553        Self::Degraded,
554        Self::Draining,
555    ];
556
557    /// Canonical PascalCase wire-format projection — matches the
558    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
559    /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
560    /// stamps on `status.phase`. Pinned by
561    /// `pool_phase_as_str_matches_serde` so a variant rename can't
562    /// drift between the typed surface, the CRD enum, the YAML wire
563    /// format AND any future operator-facing diagnostic that
564    /// composes `phase={phase}` via Display rather than a hard-coded
565    /// literal that would silently rot. Display + FromStr triad
566    /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
567    /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
568    /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
569    pub const fn as_str(self) -> &'static str {
570        match self {
571            Self::Initializing => "Initializing",
572            Self::Steady => "Steady",
573            Self::ScalingUp => "ScalingUp",
574            Self::ScalingDown => "ScalingDown",
575            Self::Degraded => "Degraded",
576            Self::Draining => "Draining",
577        }
578    }
579
580    /// Is the pool fully converged — supply matches desired, no
581    /// reconciler-driven population change pending? Closed-set match
582    /// (not `matches!`) so a future variant triggers the compiler's
583    /// exhaustiveness check at this site rather than silently
584    /// defaulting to `false`. Paired with `is_terminal` they form
585    /// the two-axis projection that future status aggregators
586    /// (operator-facing fleet health, `feira pool list --healthy`,
587    /// the SSE filter "show non-steady pools") dispatch against —
588    /// `is_steady && !is_terminal` ⇒ converged (goal state);
589    /// `!is_steady && is_terminal` ⇒ being deleted (no future
590    /// spawn); `!is_steady && !is_terminal` ⇒ transient
591    /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
592    /// is in motion toward desired). The impossible bucket
593    /// `(true, true)` — a draining pool that's somehow also steady
594    /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
595    pub const fn is_steady(self) -> bool {
596        match self {
597            Self::Steady => true,
598            Self::Initializing
599            | Self::ScalingUp
600            | Self::ScalingDown
601            | Self::Degraded
602            | Self::Draining => false,
603        }
604    }
605
606    /// Is the pool in its absorbing exit state — deletion-stamped,
607    /// reconciler is reaping every member, no spawn will ever
608    /// happen again? Closed-set match so a future variant triggers
609    /// the compiler's exhaustiveness check. See `is_steady` for the
610    /// predicate-pair contract + bucket definitions.
611    pub const fn is_terminal(self) -> bool {
612        match self {
613            Self::Draining => true,
614            Self::Initializing
615            | Self::Steady
616            | Self::ScalingUp
617            | Self::ScalingDown
618            | Self::Degraded => false,
619        }
620    }
621}
622
623// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
624// + `impl fmt::Display for PoolPhase` are generated by
625// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
626// `label` delegates to the inherent `PoolPhase::as_str` via
627// `#[closed_set(via = "as_str")]` so the operator-facing
628// `phase={phase}` Display composition keeps reading the same canonical
629// PascalCase projection while a generic `T: ClosedSet` consumer (a
630// status-aggregator filter, the `feira pool list --healthy` predicate, a
631// future SSE event router) can walk every variant without knowing the
632// closed set lives in `tatara-process::pool`; Display delegates to the
633// same inherent projection via `#[closed_set(display)]` so the
634// `phase={phase}` composition stays pinned on the closed-set algebra
635// rather than a hand-rolled `fmt::Display` block.
636
637// `pub struct UnknownPoolPhase(pub String)` is generated by
638// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
639// on the enum declaration above. The auto-derived label `"pool phase"`
640// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
641// verbatim. Symmetric to [`UnknownMemberState`],
642// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
643// [`crate::lifetime::UnknownTeardownPolicy`],
644// [`crate::boundary::UnknownConditionKind`], and
645// [`crate::phase::UnknownPhase`].
646
647/// Standard K8s Condition shape (kept local so tatara-process doesn't
648/// depend on k8s_openapi types in its public schema).
649#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
650#[serde(rename_all = "camelCase")]
651pub struct PoolCondition {
652    pub type_: String,
653    pub status: String,
654    pub reason: String,
655    pub message: String,
656    pub last_transition_time: DateTime<Utc>,
657}
658
659/// What the pool does when an allocation releases a member.
660///
661/// Sibling closed-set on the `EphemeralPool` axis:
662/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
663/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
664/// (the *release*-time counterpart for non-pooled ephemeral envs),
665/// [`crate::boundary::ConditionKind::ALL`],
666/// [`crate::lifetime::LifetimeKind::ALL`],
667/// [`crate::intent::IntentKind::ALL`],
668/// [`crate::phase::ProcessPhase::ALL`],
669/// [`crate::signal::ProcessSignal::ALL`].
670#[derive(
671    Clone,
672    Copy,
673    Debug,
674    Hash,
675    PartialEq,
676    Eq,
677    Serialize,
678    Deserialize,
679    JsonSchema,
680    Default,
681    tatara_closed_set::DeriveClosedSet,
682)]
683#[serde(rename_all = "PascalCase")]
684#[closed_set(via = "as_str", generate_unknown, display)]
685pub enum ReturnPolicy {
686    /// Tear down the Process + create a fresh one. Safe but slow
687    /// (1-2 min spin-up before the slot is Free again).
688    #[default]
689    Replace,
690    /// Keep the Process running; run a typed `:reset` Job that wipes
691    /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
692    /// the reset Job being correct for the workload. API-authoritative
693    /// systems are natural fits because the control API owns all state.
694    Reset,
695    /// Keep the Process indefinitely after release (debugging aid;
696    /// operator must `feira pool reap NAME` to clean up). Useful for
697    /// post-mortem of a flaky test.
698    Keep,
699}
700
701impl ReturnPolicy {
702    /// The closed set of return policies — single source of truth that
703    /// drives the `as_str` / Display / `FromStr` triad and the
704    /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
705    /// fourth variant lands at one `ALL` entry + one `as_str` arm +
706    /// one arm per predicate — exhaustively checked by the compiler
707    /// (the `[Self; 3]` array literal forces the arity) and by the
708    /// predicate-pair injectivity test (a new variant must land in
709    /// its own (keeps_process, runs_reset_job) bucket or the author
710    /// has to extend the consumer dispatch in
711    /// `tatara-pool-reconciler::return_policy::plan_return`).
712    pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
713
714    /// Canonical PascalCase wire-format projection — matches the
715    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
716    /// `enum:` enumeration the pool reconciler stamps on the
717    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
718    /// `return_policy_as_str_matches_serde` so a variant rename can't
719    /// drift between the typed surface, the CRD enum, the YAML wire
720    /// format AND any future operator-facing diagnostic that composes
721    /// `policy={policy}` via Display rather than a hard-coded literal.
722    pub const fn as_str(self) -> &'static str {
723        match self {
724            Self::Replace => "Replace",
725            Self::Reset => "Reset",
726            Self::Keep => "Keep",
727        }
728    }
729
730    /// Does the pool keep the backing Process alive across release?
731    /// Closed-set match (not `matches!`) so a future variant triggers
732    /// the compiler's exhaustiveness check at this site rather than
733    /// silently defaulting to `false`. Paired with `runs_reset_job`
734    /// they form the two-axis projection that the consumer in
735    /// `tatara-pool-reconciler::return_policy::plan_return` matches
736    /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
737    /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
738    /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
739    /// pair is `(false, false) | (true, true) | (true, false)` —
740    /// pinned injective by
741    /// `return_policy_predicate_pair_is_injective`.
742    pub const fn keeps_process(self) -> bool {
743        match self {
744            Self::Replace => false,
745            Self::Reset | Self::Keep => true,
746        }
747    }
748
749    /// Does the policy run a typed `:reset` Job to wipe state in
750    /// place? See `keeps_process` for the closed-match rationale +
751    /// the predicate-pair contract.
752    pub const fn runs_reset_job(self) -> bool {
753        match self {
754            Self::Reset => true,
755            Self::Replace | Self::Keep => false,
756        }
757    }
758}
759
760// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
761// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
762// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
763// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
764// `#[closed_set(via = "as_str")]` so the
765// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
766// reading the canonical PascalCase projection that matches the CRD
767// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
768// plugs in without knowing the enum lives in `tatara-process::pool`;
769// Display delegates to the same inherent projection via
770// `#[closed_set(display)]` so the `policy={policy}` diagnostic
771// composition stays pinned on the closed-set algebra.
772
773// `pub struct UnknownReturnPolicy(pub String)` is generated by
774// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
775// on the enum declaration above. The auto-derived label `"return policy"`
776// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
777// verbatim. Symmetric to [`UnknownReplacementPolicy`],
778// [`UnknownMemberState`], [`UnknownPoolPhase`],
779// [`crate::lifetime::UnknownTeardownPolicy`],
780// [`crate::boundary::UnknownConditionKind`], and
781// [`crate::phase::UnknownPhase`].
782
783/// Routing selector — matches an `EphemeralAllocation`'s requestor
784/// against pool-eligibility predicates.
785#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
786#[serde(rename_all = "camelCase")]
787pub struct PoolSelector {
788    /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
789    /// Empty = match every repo.
790    #[serde(default)]
791    pub repos: Vec<String>,
792
793    /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
794    /// Empty = match every branch.
795    #[serde(default)]
796    pub branches: Vec<String>,
797
798    /// PR labels (all-must-match, AND semantics). Empty = no label
799    /// requirement.
800    #[serde(default)]
801    pub pr_labels: Vec<String>,
802
803    /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
804    /// "manual", "ci-run"). Empty = any kind.
805    #[serde(default)]
806    pub kinds: Vec<String>,
807}
808
809impl PoolSelector {
810    /// Does this selector match the given allocation routing key?
811    /// Pure: no side effects.
812    pub fn matches(&self, key: &MatchKey<'_>) -> bool {
813        glob_any(&self.repos, key.repo)
814            && glob_any(&self.branches, key.branch)
815            && labels_subset(&self.pr_labels, key.pr_labels)
816            && kind_any(&self.kinds, key.kind)
817    }
818
819    /// Specificity score — higher = more specific. Used by the
820    /// reconciler to break ties between selectors that all match.
821    pub fn specificity(&self) -> u32 {
822        let mut score = 0;
823        if !self.repos.is_empty() {
824            score += 8;
825        }
826        if !self.branches.is_empty() {
827            score += 4;
828        }
829        score += (self.pr_labels.len() as u32) * 2;
830        if !self.kinds.is_empty() {
831            score += 1;
832        }
833        score
834    }
835}
836
837/// Allocation routing key — what the reconciler matches against pool selectors.
838#[derive(Clone, Copy, Debug)]
839pub struct MatchKey<'a> {
840    pub repo: &'a str,
841    pub branch: &'a str,
842    pub pr_labels: &'a [String],
843    pub kind: &'a str,
844}
845
846fn glob_any(patterns: &[String], value: &str) -> bool {
847    if patterns.is_empty() {
848        return true;
849    }
850    patterns.iter().any(|p| glob_match(p, value))
851}
852
853fn kind_any(kinds: &[String], value: &str) -> bool {
854    if kinds.is_empty() {
855        return true;
856    }
857    kinds.iter().any(|k| k == value)
858}
859
860fn labels_subset(required: &[String], present: &[String]) -> bool {
861    required.iter().all(|r| present.iter().any(|p| p == r))
862}
863
864/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
865/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
866/// matches anything.
867fn glob_match(pattern: &str, value: &str) -> bool {
868    if pattern.is_empty() {
869        return true;
870    }
871    if let Some(prefix) = pattern.strip_suffix('*') {
872        value.starts_with(prefix)
873    } else {
874        pattern == value
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    // The closed-set tests below call `T::from_str(bad)` via the
882    // derive-generated `FromStr` impls — bring the trait into scope at
883    // the test module so the lib body doesn't carry an otherwise-unused
884    // `use std::str::FromStr;` at the file head.
885    use std::str::FromStr;
886
887    #[test]
888    fn glob_trailing_star_matches_prefix() {
889        assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
890        assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
891        assert!(glob_match("release-*", "release-2026-05"));
892        assert!(!glob_match("release-*", "main"));
893        assert!(glob_match("main", "main"));
894        assert!(!glob_match("main", "develop"));
895    }
896
897    #[test]
898    fn empty_selector_matches_anything() {
899        let s = PoolSelector::default();
900        assert!(s.matches(&MatchKey {
901            repo: "any/repo",
902            branch: "any-branch",
903            pr_labels: &[],
904            kind: "any",
905        }));
906    }
907
908    #[test]
909    fn repo_glob_filters_match_key() {
910        let s = PoolSelector {
911            repos: vec!["pleme-io/demo-*".into()],
912            ..Default::default()
913        };
914        assert!(s.matches(&MatchKey {
915            repo: "pleme-io/demo-app",
916            branch: "x",
917            pr_labels: &[],
918            kind: "y",
919        }));
920        assert!(!s.matches(&MatchKey {
921            repo: "pleme-io/other-repo",
922            branch: "x",
923            pr_labels: &[],
924            kind: "y",
925        }));
926    }
927
928    #[test]
929    fn pr_labels_require_all() {
930        let s = PoolSelector {
931            pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
932            ..Default::default()
933        };
934        // Both labels present → match.
935        assert!(s.matches(&MatchKey {
936            repo: "x",
937            branch: "y",
938            pr_labels: &[
939                "needs-ephemeral".into(),
940                "integration".into(),
941                "extra".into()
942            ],
943            kind: "z",
944        }));
945        // One label missing → no match.
946        assert!(!s.matches(&MatchKey {
947            repo: "x",
948            branch: "y",
949            pr_labels: &["needs-ephemeral".into()],
950            kind: "z",
951        }));
952    }
953
954    #[test]
955    fn specificity_ranks_more_constrained_higher() {
956        let general = PoolSelector::default();
957        let specific = PoolSelector {
958            repos: vec!["pleme-io/*".into()],
959            branches: vec!["main".into()],
960            pr_labels: vec!["needs-ephemeral".into()],
961            kinds: vec!["github-pr".into()],
962        };
963        assert!(specific.specificity() > general.specificity());
964    }
965
966    #[test]
967    fn return_policy_defaults_to_replace() {
968        assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
969    }
970
971    #[test]
972    fn pool_phase_defaults_to_initializing() {
973        assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
974    }
975
976    // ── closed-set algebra contracts for ReplacementPolicy
977    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
978
979    /// Structural well-formedness of [`ReplacementPolicy`] as a
980    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
981    /// testkit lift that pins all three structural invariants (`ALL`
982    /// is non-empty, every variant round-trips through
983    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
984    /// outside the closed set) at ONE call site. Replaces the hand-
985    /// derived `replacement_policy_all_is_unique_and_complete` +
986    /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
987    /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
988    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
989    /// exercises the same code path the pool reconciler hits when
990    /// parsing a CRD `enum:`-validated value back to the typed policy.
991    #[test]
992    fn replacement_policy_is_well_formed_closed_set() {
993        tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
994    }
995
996    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
997    /// output verbatim for every variant. A future variant rename (or
998    /// an `as_str` arm typo) lands here at one site, instead of
999    /// drifting between the typed surface, the CRD enum, and the
1000    /// YAML wire format.
1001    #[test]
1002    fn replacement_policy_as_str_matches_serde() {
1003        crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
1004    }
1005
1006    /// The Display impl IS `as_str` — pinning this lets future callers
1007    /// reach for either projection without drift. The operator-facing
1008    /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1009    /// composes through Display rather than through a hard-coded
1010    /// variant string.
1011    #[test]
1012    fn replacement_policy_display_matches_as_str() {
1013        crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
1014    }
1015
1016    /// `FromStr` rejects strings that aren't in the canonical
1017    /// projection — lowercased / typo / cross-axis-leaked — and the
1018    /// error echoes the input verbatim so the operator-facing
1019    /// diagnostic carries the offending value, not a normalized form.
1020    /// The empty-input arm is pinned by
1021    /// [`replacement_policy_is_well_formed_closed_set`] via the
1022    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1023    /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1024    /// newtype, which the trait's `make_unknown` can't see.
1025    #[test]
1026    fn unknown_replacement_policy_errors() {
1027        for bad in [
1028            "replaceimmediate",
1029            "PAUSEPOOL",
1030            "Replace-Immediate",
1031            "hold_failed",
1032            "Pause",
1033            "Reset",
1034        ] {
1035            let err = ReplacementPolicy::from_str(bad).unwrap_err();
1036            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1037        }
1038    }
1039
1040    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1041    /// documented per-variant on-failure behavior.
1042    #[test]
1043    fn replacement_policy_predicate_truth_tables() {
1044        assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1045        assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1046
1047        assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1048        assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1049
1050        assert!(!ReplacementPolicy::PausePool.replaces_failed());
1051        assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1052    }
1053
1054    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1055    /// predicates simultaneously — the two on-failure actions
1056    /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1057    /// A future `ReplacementPolicy::PauseAndReap` that returned true
1058    /// from both would FAIL here, forcing the author to either pick
1059    /// one bucket or extend the consumer dispatch site in
1060    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1061    /// deliberately rather than silently double-firing both branches.
1062    #[test]
1063    fn replacement_policy_predicates_are_disjoint() {
1064        for policy in ReplacementPolicy::ALL {
1065            assert!(
1066                !(policy.replaces_failed() && policy.pauses_on_failure()),
1067                "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1068            );
1069        }
1070    }
1071
1072    /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1073    /// pauses_on_failure)` is injective across `ALL`. Each variant
1074    /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1075    /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1076    /// this with the disjointness contract above forces a future
1077    /// variant to land in a fresh `(replaces_failed,
1078    /// pauses_on_failure)` bucket — or the author extends the consumer
1079    /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1080    /// to recognize the new projection bucket.
1081    #[test]
1082    fn replacement_policy_predicate_pair_is_injective() {
1083        let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1084            .into_iter()
1085            .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1086            .collect();
1087        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1088        assert_eq!(
1089            projections.len(),
1090            unique.len(),
1091            "predicate pair projection is not injective: {projections:?}",
1092        );
1093    }
1094
1095    /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1096    /// returns the variant tagged `#[default]` in the enum, AND that
1097    /// variant reaps (the production-safe behavior). A future #[default]
1098    /// rename without flipping the predicates fails here.
1099    #[test]
1100    fn replacement_policy_default_replaces_failed() {
1101        let d = ReplacementPolicy::default();
1102        assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1103        assert!(d.replaces_failed());
1104        assert!(!d.pauses_on_failure());
1105    }
1106
1107    #[test]
1108    fn kinds_filter_to_known_set() {
1109        let s = PoolSelector {
1110            kinds: vec!["github-pr".into(), "manual".into()],
1111            ..Default::default()
1112        };
1113        assert!(s.matches(&MatchKey {
1114            repo: "x",
1115            branch: "y",
1116            pr_labels: &[],
1117            kind: "github-pr",
1118        }));
1119        assert!(!s.matches(&MatchKey {
1120            repo: "x",
1121            branch: "y",
1122            pr_labels: &[],
1123            kind: "scheduled",
1124        }));
1125    }
1126
1127    // ── closed-set algebra contracts for ReturnPolicy
1128    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
1129
1130    /// Structural well-formedness of [`ReturnPolicy`] as a
1131    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1132    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1133    /// above.
1134    #[test]
1135    fn return_policy_is_well_formed_closed_set() {
1136        tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1137    }
1138
1139    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1140    /// output verbatim for every variant. A future variant rename (or
1141    /// an `as_str` arm typo) lands here at one site, instead of
1142    /// drifting between the typed surface, the CRD enum, and the
1143    /// YAML wire format.
1144    #[test]
1145    fn return_policy_as_str_matches_serde() {
1146        crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
1147    }
1148
1149    /// The Display impl IS `as_str` — pinning this lets future callers
1150    /// reach for either projection without drift, mirroring the
1151    /// `ReplacementPolicy` discipline.
1152    #[test]
1153    fn return_policy_display_matches_as_str() {
1154        crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
1155    }
1156
1157    /// `FromStr` rejects strings that aren't in the canonical
1158    /// projection — lowercased / typo / cross-axis-leaked — and the
1159    /// error echoes the input verbatim so the operator-facing
1160    /// diagnostic carries the offending value, not a normalized form.
1161    /// The empty-input arm is pinned by
1162    /// [`return_policy_is_well_formed_closed_set`] via the
1163    /// `tatara_lisp::ClosedSet` testkit.
1164    #[test]
1165    fn unknown_return_policy_errors() {
1166        for bad in [
1167            "replace",
1168            "RESET",
1169            "Re-place",
1170            "keep_for_inspection",
1171            "DeleteAndRespawn",
1172            "ReplaceImmediate",
1173        ] {
1174            let err = ReturnPolicy::from_str(bad).unwrap_err();
1175            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1176        }
1177    }
1178
1179    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1180    /// documented per-variant on-release behavior.
1181    #[test]
1182    fn return_policy_predicate_truth_tables() {
1183        assert!(!ReturnPolicy::Replace.keeps_process());
1184        assert!(!ReturnPolicy::Replace.runs_reset_job());
1185
1186        assert!(ReturnPolicy::Reset.keeps_process());
1187        assert!(ReturnPolicy::Reset.runs_reset_job());
1188
1189        assert!(ReturnPolicy::Keep.keeps_process());
1190        assert!(!ReturnPolicy::Keep.runs_reset_job());
1191    }
1192
1193    /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1194    /// You cannot run a typed `:reset` Job against a Process you've
1195    /// just deleted; the impossible bucket `(false, true)` must stay
1196    /// empty. A future variant returning true from `runs_reset_job`
1197    /// while returning false from `keeps_process` fails here, which
1198    /// forces the author to either flip `keeps_process` to true or
1199    /// extend the consumer dispatch site in
1200    /// `tatara-pool-reconciler::return_policy::plan_return`
1201    /// deliberately rather than letting an impossible state slip in.
1202    #[test]
1203    fn return_policy_reset_implies_keeps_process() {
1204        for policy in ReturnPolicy::ALL {
1205            if policy.runs_reset_job() {
1206                assert!(
1207                    policy.keeps_process(),
1208                    "{policy:?} runs a reset job but does not keep the process",
1209                );
1210            }
1211        }
1212    }
1213
1214    /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1215    /// is injective across `ALL`. Each variant projects to its own
1216    /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1217    /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1218    /// inspection. Pairing this with the implication contract above
1219    /// forces a future variant to land in a fresh
1220    /// `(keeps_process, runs_reset_job)` bucket — or the author
1221    /// extends the consumer dispatch in
1222    /// `tatara-pool-reconciler::return_policy::plan_return` to
1223    /// recognize the new projection bucket.
1224    #[test]
1225    fn return_policy_predicate_pair_is_injective() {
1226        let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1227            .into_iter()
1228            .map(|p| (p.keeps_process(), p.runs_reset_job()))
1229            .collect();
1230        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1231        assert_eq!(
1232            projections.len(),
1233            unique.len(),
1234            "predicate pair projection is not injective: {projections:?}",
1235        );
1236    }
1237
1238    /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1239    /// the variant tagged `#[default]` in the enum, AND that variant
1240    /// is the safe "tear down + respawn" behavior — neither keeps the
1241    /// process nor runs a reset Job. A future `#[default]` rename
1242    /// without flipping the predicates fails here.
1243    #[test]
1244    fn return_policy_default_is_replace_and_neither_predicate_fires() {
1245        let d = ReturnPolicy::default();
1246        assert_eq!(d, ReturnPolicy::Replace);
1247        assert!(!d.keeps_process());
1248        assert!(!d.runs_reset_job());
1249    }
1250
1251    // ── closed-set algebra contracts for MemberState
1252    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1253
1254    /// Structural well-formedness of [`MemberState`] as a
1255    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1256    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1257    /// and [`return_policy_is_well_formed_closed_set`] above.
1258    #[test]
1259    fn member_state_is_well_formed_closed_set() {
1260        tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1261    }
1262
1263    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1264    /// output verbatim for every variant. A future variant rename (or
1265    /// an `as_str` arm typo) lands here at one site, instead of
1266    /// drifting between the typed surface, the CRD enum, and the YAML
1267    /// wire format the pool reconciler stamps on
1268    /// `status.members[].state`.
1269    #[test]
1270    fn member_state_as_str_matches_serde() {
1271        crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
1272    }
1273
1274    /// The Display impl IS `as_str` — pinning this lets future callers
1275    /// reach for either projection without drift. Any operator-facing
1276    /// "state={state}" diagnostic that composes through Display
1277    /// inherits the canonical wire-format string automatically.
1278    #[test]
1279    fn member_state_display_matches_as_str() {
1280        crate::tagged_union::assert_display_matches_label::<MemberState>();
1281    }
1282
1283    /// `FromStr` rejects strings that aren't in the canonical
1284    /// projection — lowercased / typo / cross-axis-leaked — and
1285    /// the error echoes the input verbatim so the operator-facing
1286    /// diagnostic carries the offending value, not a normalized form.
1287    /// The empty-input arm is pinned by
1288    /// [`member_state_is_well_formed_closed_set`] via the
1289    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1290    /// pin the closed-set REJECTION contract that the trait can't see:
1291    /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1292    /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1293    /// `ProcessPhase`) but MUST reject here, because the codomains
1294    /// are disjoint.
1295    #[test]
1296    fn unknown_member_state_errors() {
1297        for bad in [
1298            "free",
1299            "SPAWNING",
1300            "Free-State",
1301            "allocated_now",
1302            "ReplaceImmediate", // ReplacementPolicy-axis leak
1303            "Reset",            // ReturnPolicy-axis leak
1304            "Attested",         // ProcessPhase-axis leak
1305        ] {
1306            let err = MemberState::from_str(bad).unwrap_err();
1307            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1308        }
1309    }
1310
1311    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1312    /// documented per-variant lifecycle role. The pool reconciler's
1313    /// `pool_phase_from_members` supply calc collapses
1314    /// `count_state(Free) + count_state(Spawning)` into one
1315    /// `counts_toward_supply` filter; this table pins the per-variant
1316    /// projection that consumer depends on.
1317    #[test]
1318    fn member_state_predicate_truth_tables() {
1319        assert!(!MemberState::Spawning.is_failed());
1320        assert!(MemberState::Spawning.counts_toward_supply());
1321
1322        assert!(!MemberState::Free.is_failed());
1323        assert!(MemberState::Free.counts_toward_supply());
1324
1325        assert!(!MemberState::Allocated.is_failed());
1326        assert!(!MemberState::Allocated.counts_toward_supply());
1327
1328        assert!(!MemberState::Returning.is_failed());
1329        assert!(!MemberState::Returning.counts_toward_supply());
1330
1331        assert!(MemberState::Failed.is_failed());
1332        assert!(!MemberState::Failed.counts_toward_supply());
1333    }
1334
1335    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1336    /// `is_failed` and `counts_toward_supply` simultaneously — a
1337    /// failed member can never be counted as available capacity. A
1338    /// future variant that returned true from both would FAIL here,
1339    /// forcing the author to either drop it from supply, or extend
1340    /// the consumer's bucketing in
1341    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1342    /// deliberately rather than silently inflating the pool's supply
1343    /// count with failed slots.
1344    #[test]
1345    fn member_state_failed_implies_no_supply() {
1346        for state in MemberState::ALL {
1347            assert!(
1348                !(state.is_failed() && state.counts_toward_supply()),
1349                "{state:?} returns true from both is_failed and counts_toward_supply — \
1350                 a failed member can never be counted as available pool capacity",
1351            );
1352        }
1353    }
1354
1355    /// COVERAGE CONTRACT: every variant lands somewhere — either
1356    /// in supply, or as a failed slot, or as an in-use bucket
1357    /// (`Allocated | Returning`). A future variant that returns
1358    /// `false` from `counts_toward_supply` AND `false` from
1359    /// `is_failed` is fine *iff* it represents an in-use slot; this
1360    /// test pins the existing variants in their declared buckets so
1361    /// the consumer-side dispatch in
1362    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1363    /// stays grounded.
1364    #[test]
1365    fn member_state_buckets_cover_every_variant() {
1366        let mut supply = 0u32;
1367        let mut failed = 0u32;
1368        let mut in_use = 0u32;
1369        for state in MemberState::ALL {
1370            match (state.is_failed(), state.counts_toward_supply()) {
1371                (true, false) => failed += 1,
1372                (false, true) => supply += 1,
1373                (false, false) => in_use += 1,
1374                (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1375            }
1376        }
1377        assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1378        assert_eq!(failed, 1, "failed bucket: Failed");
1379        assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1380        assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1381    }
1382
1383    // ── closed-set algebra contracts for PoolPhase
1384    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1385
1386    /// Structural well-formedness of [`PoolPhase`] as a
1387    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1388    /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1389    #[test]
1390    fn pool_phase_is_well_formed_closed_set() {
1391        tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1392    }
1393
1394    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1395    /// output verbatim for every variant. A future variant rename (or
1396    /// an `as_str` arm typo) lands here at one site, instead of
1397    /// drifting between the typed surface, the CRD enum, and the YAML
1398    /// wire format the pool reconciler stamps on `status.phase`.
1399    #[test]
1400    fn pool_phase_as_str_matches_serde() {
1401        crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
1402    }
1403
1404    /// The Display impl IS `as_str` — pinning this lets future callers
1405    /// reach for either projection without drift. Any operator-facing
1406    /// "phase={phase}" diagnostic that composes through Display
1407    /// inherits the canonical wire-format string automatically.
1408    #[test]
1409    fn pool_phase_display_matches_as_str() {
1410        crate::tagged_union::assert_display_matches_label::<PoolPhase>();
1411    }
1412
1413    /// `FromStr` rejects strings that aren't in the canonical
1414    /// projection — lowercased / typo / cross-axis-leaked — and
1415    /// the error echoes the input verbatim so the operator-facing
1416    /// diagnostic carries the offending value, not a normalized form.
1417    /// The empty-input arm is pinned by
1418    /// [`pool_phase_is_well_formed_closed_set`] via the
1419    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1420    /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1421    /// closed-set REJECTION contract that the trait can't see — those
1422    /// are valid sibling-axis labels but MUST reject here.
1423    #[test]
1424    fn unknown_pool_phase_errors() {
1425        for bad in [
1426            "steady",
1427            "SCALINGUP",
1428            "Scaling-Up",
1429            "scaling_down",
1430            "Free",       // MemberState-axis leak
1431            "Replace",    // ReturnPolicy-axis leak
1432            "Attested",   // ProcessPhase-axis leak
1433            "HoldFailed", // ReplacementPolicy-axis leak
1434        ] {
1435            let err = PoolPhase::from_str(bad).unwrap_err();
1436            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1437        }
1438    }
1439
1440    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1441    /// documented per-variant lifecycle role. Pinning this table at
1442    /// one site means any future status-aggregator surface
1443    /// (`feira pool list --healthy`, the SSE filter, the desired-loop
1444    /// heartbeat short-circuit) reads the same projection that the
1445    /// reconciler writes.
1446    #[test]
1447    fn pool_phase_predicate_truth_tables() {
1448        assert!(!PoolPhase::Initializing.is_steady());
1449        assert!(!PoolPhase::Initializing.is_terminal());
1450
1451        assert!(PoolPhase::Steady.is_steady());
1452        assert!(!PoolPhase::Steady.is_terminal());
1453
1454        assert!(!PoolPhase::ScalingUp.is_steady());
1455        assert!(!PoolPhase::ScalingUp.is_terminal());
1456
1457        assert!(!PoolPhase::ScalingDown.is_steady());
1458        assert!(!PoolPhase::ScalingDown.is_terminal());
1459
1460        assert!(!PoolPhase::Degraded.is_steady());
1461        assert!(!PoolPhase::Degraded.is_terminal());
1462
1463        assert!(!PoolPhase::Draining.is_steady());
1464        assert!(PoolPhase::Draining.is_terminal());
1465    }
1466
1467    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1468    /// `is_steady` and `is_terminal` simultaneously — a draining pool
1469    /// is by definition transitioning OUT, not the goal converged
1470    /// state. A future variant that returned true from both would
1471    /// FAIL here, forcing the author to either pick one bucket or
1472    /// extend the consumer dispatch sites (status aggregators,
1473    /// heartbeat short-circuit) deliberately rather than silently
1474    /// double-firing both branches.
1475    #[test]
1476    fn pool_phase_steady_excludes_terminal() {
1477        for phase in PoolPhase::ALL {
1478            assert!(
1479                !(phase.is_steady() && phase.is_terminal()),
1480                "{phase:?} returns true from both is_steady and is_terminal — \
1481                 a draining pool is by definition not the converged goal state",
1482            );
1483        }
1484    }
1485
1486    /// COVERAGE CONTRACT: every variant lands somewhere — either the
1487    /// converged goal (`Steady`), the absorbing exit (`Draining`),
1488    /// or the transient bucket (`Initializing | ScalingUp |
1489    /// ScalingDown | Degraded` — pool is in motion toward desired).
1490    /// A future variant that returns `false` from BOTH predicates is
1491    /// fine *iff* it represents an in-motion state; this test pins
1492    /// the existing variants in their declared buckets so the
1493    /// projection consumers stay grounded.
1494    #[test]
1495    fn pool_phase_buckets_cover_every_variant() {
1496        let mut converged = 0u32;
1497        let mut terminal = 0u32;
1498        let mut transient = 0u32;
1499        for phase in PoolPhase::ALL {
1500            match (phase.is_steady(), phase.is_terminal()) {
1501                (true, false) => converged += 1,
1502                (false, true) => terminal += 1,
1503                (false, false) => transient += 1,
1504                (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
1505            }
1506        }
1507        assert_eq!(converged, 1, "converged bucket: Steady");
1508        assert_eq!(terminal, 1, "terminal bucket: Draining");
1509        assert_eq!(
1510            transient, 4,
1511            "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
1512        );
1513        assert_eq!(
1514            converged + terminal + transient,
1515            PoolPhase::ALL.len() as u32
1516        );
1517    }
1518
1519    /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
1520    /// variant a freshly-admitted pool should land in — `Initializing`
1521    /// — AND that variant is neither steady (no members yet) nor
1522    /// terminal (not deletion-stamped). A future `Default` rename
1523    /// without flipping the predicates fails here.
1524    #[test]
1525    fn pool_phase_default_is_initializing_in_transient_bucket() {
1526        let d = PoolPhase::default();
1527        assert_eq!(d, PoolPhase::Initializing);
1528        assert!(!d.is_steady());
1529        assert!(!d.is_terminal());
1530    }
1531}