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: akeyless-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/akeyless-*"]
48///     branches: ["main", "release-*"]
49///     prLabels: ["needs-akeyless"]
50///   template:
51///     aplicacao:
52///       chartRef: "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment"
53///       version: "0.5.5"
54///       profile: "gateway-with-internal-saas"
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. Akeyless-style
693    /// systems are natural fits because the SaaS API is authoritative.
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/akeyless-deployment"));
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/akeyless-*".into()],
912            ..Default::default()
913        };
914        assert!(s.matches(&MatchKey {
915            repo: "pleme-io/akeyless-deployment",
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-akeyless".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-akeyless".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-akeyless".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-akeyless".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        for policy in ReplacementPolicy::ALL {
1004            let serialized = serde_json::to_string(&policy).expect("serialize");
1005            let unquoted = serialized
1006                .trim_start_matches('"')
1007                .trim_end_matches('"')
1008                .to_string();
1009            assert_eq!(
1010                unquoted,
1011                policy.as_str(),
1012                "as_str drift for {policy:?}: as_str={} serde={unquoted}",
1013                policy.as_str()
1014            );
1015        }
1016    }
1017
1018    /// The Display impl IS `as_str` — pinning this lets future callers
1019    /// reach for either projection without drift. The operator-facing
1020    /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1021    /// composes through Display rather than through a hard-coded
1022    /// variant string.
1023    #[test]
1024    fn replacement_policy_display_matches_as_str() {
1025        for policy in ReplacementPolicy::ALL {
1026            assert_eq!(policy.to_string(), policy.as_str());
1027        }
1028    }
1029
1030    /// `FromStr` rejects strings that aren't in the canonical
1031    /// projection — lowercased / typo / cross-axis-leaked — and the
1032    /// error echoes the input verbatim so the operator-facing
1033    /// diagnostic carries the offending value, not a normalized form.
1034    /// The empty-input arm is pinned by
1035    /// [`replacement_policy_is_well_formed_closed_set`] via the
1036    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1037    /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1038    /// newtype, which the trait's `make_unknown` can't see.
1039    #[test]
1040    fn unknown_replacement_policy_errors() {
1041        for bad in [
1042            "replaceimmediate",
1043            "PAUSEPOOL",
1044            "Replace-Immediate",
1045            "hold_failed",
1046            "Pause",
1047            "Reset",
1048        ] {
1049            let err = ReplacementPolicy::from_str(bad).unwrap_err();
1050            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1051        }
1052    }
1053
1054    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1055    /// documented per-variant on-failure behavior.
1056    #[test]
1057    fn replacement_policy_predicate_truth_tables() {
1058        assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1059        assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1060
1061        assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1062        assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1063
1064        assert!(!ReplacementPolicy::PausePool.replaces_failed());
1065        assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1066    }
1067
1068    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1069    /// predicates simultaneously — the two on-failure actions
1070    /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1071    /// A future `ReplacementPolicy::PauseAndReap` that returned true
1072    /// from both would FAIL here, forcing the author to either pick
1073    /// one bucket or extend the consumer dispatch site in
1074    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1075    /// deliberately rather than silently double-firing both branches.
1076    #[test]
1077    fn replacement_policy_predicates_are_disjoint() {
1078        for policy in ReplacementPolicy::ALL {
1079            assert!(
1080                !(policy.replaces_failed() && policy.pauses_on_failure()),
1081                "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1082            );
1083        }
1084    }
1085
1086    /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1087    /// pauses_on_failure)` is injective across `ALL`. Each variant
1088    /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1089    /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1090    /// this with the disjointness contract above forces a future
1091    /// variant to land in a fresh `(replaces_failed,
1092    /// pauses_on_failure)` bucket — or the author extends the consumer
1093    /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1094    /// to recognize the new projection bucket.
1095    #[test]
1096    fn replacement_policy_predicate_pair_is_injective() {
1097        let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1098            .into_iter()
1099            .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1100            .collect();
1101        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1102        assert_eq!(
1103            projections.len(),
1104            unique.len(),
1105            "predicate pair projection is not injective: {projections:?}",
1106        );
1107    }
1108
1109    /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1110    /// returns the variant tagged `#[default]` in the enum, AND that
1111    /// variant reaps (the production-safe behavior). A future #[default]
1112    /// rename without flipping the predicates fails here.
1113    #[test]
1114    fn replacement_policy_default_replaces_failed() {
1115        let d = ReplacementPolicy::default();
1116        assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1117        assert!(d.replaces_failed());
1118        assert!(!d.pauses_on_failure());
1119    }
1120
1121    #[test]
1122    fn kinds_filter_to_known_set() {
1123        let s = PoolSelector {
1124            kinds: vec!["github-pr".into(), "manual".into()],
1125            ..Default::default()
1126        };
1127        assert!(s.matches(&MatchKey {
1128            repo: "x",
1129            branch: "y",
1130            pr_labels: &[],
1131            kind: "github-pr",
1132        }));
1133        assert!(!s.matches(&MatchKey {
1134            repo: "x",
1135            branch: "y",
1136            pr_labels: &[],
1137            kind: "scheduled",
1138        }));
1139    }
1140
1141    // ── closed-set algebra contracts for ReturnPolicy
1142    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
1143
1144    /// Structural well-formedness of [`ReturnPolicy`] as a
1145    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1146    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1147    /// above.
1148    #[test]
1149    fn return_policy_is_well_formed_closed_set() {
1150        tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1151    }
1152
1153    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1154    /// output verbatim for every variant. A future variant rename (or
1155    /// an `as_str` arm typo) lands here at one site, instead of
1156    /// drifting between the typed surface, the CRD enum, and the
1157    /// YAML wire format.
1158    #[test]
1159    fn return_policy_as_str_matches_serde() {
1160        for policy in ReturnPolicy::ALL {
1161            let serialized = serde_json::to_string(&policy).expect("serialize");
1162            let unquoted = serialized
1163                .trim_start_matches('"')
1164                .trim_end_matches('"')
1165                .to_string();
1166            assert_eq!(
1167                unquoted,
1168                policy.as_str(),
1169                "as_str drift for {policy:?}: as_str={} serde={unquoted}",
1170                policy.as_str()
1171            );
1172        }
1173    }
1174
1175    /// The Display impl IS `as_str` — pinning this lets future callers
1176    /// reach for either projection without drift, mirroring the
1177    /// `ReplacementPolicy` discipline.
1178    #[test]
1179    fn return_policy_display_matches_as_str() {
1180        for policy in ReturnPolicy::ALL {
1181            assert_eq!(policy.to_string(), policy.as_str());
1182        }
1183    }
1184
1185    /// `FromStr` rejects strings that aren't in the canonical
1186    /// projection — lowercased / typo / cross-axis-leaked — and the
1187    /// error echoes the input verbatim so the operator-facing
1188    /// diagnostic carries the offending value, not a normalized form.
1189    /// The empty-input arm is pinned by
1190    /// [`return_policy_is_well_formed_closed_set`] via the
1191    /// `tatara_lisp::ClosedSet` testkit.
1192    #[test]
1193    fn unknown_return_policy_errors() {
1194        for bad in [
1195            "replace",
1196            "RESET",
1197            "Re-place",
1198            "keep_for_inspection",
1199            "DeleteAndRespawn",
1200            "ReplaceImmediate",
1201        ] {
1202            let err = ReturnPolicy::from_str(bad).unwrap_err();
1203            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1204        }
1205    }
1206
1207    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1208    /// documented per-variant on-release behavior.
1209    #[test]
1210    fn return_policy_predicate_truth_tables() {
1211        assert!(!ReturnPolicy::Replace.keeps_process());
1212        assert!(!ReturnPolicy::Replace.runs_reset_job());
1213
1214        assert!(ReturnPolicy::Reset.keeps_process());
1215        assert!(ReturnPolicy::Reset.runs_reset_job());
1216
1217        assert!(ReturnPolicy::Keep.keeps_process());
1218        assert!(!ReturnPolicy::Keep.runs_reset_job());
1219    }
1220
1221    /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1222    /// You cannot run a typed `:reset` Job against a Process you've
1223    /// just deleted; the impossible bucket `(false, true)` must stay
1224    /// empty. A future variant returning true from `runs_reset_job`
1225    /// while returning false from `keeps_process` fails here, which
1226    /// forces the author to either flip `keeps_process` to true or
1227    /// extend the consumer dispatch site in
1228    /// `tatara-pool-reconciler::return_policy::plan_return`
1229    /// deliberately rather than letting an impossible state slip in.
1230    #[test]
1231    fn return_policy_reset_implies_keeps_process() {
1232        for policy in ReturnPolicy::ALL {
1233            if policy.runs_reset_job() {
1234                assert!(
1235                    policy.keeps_process(),
1236                    "{policy:?} runs a reset job but does not keep the process",
1237                );
1238            }
1239        }
1240    }
1241
1242    /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1243    /// is injective across `ALL`. Each variant projects to its own
1244    /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1245    /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1246    /// inspection. Pairing this with the implication contract above
1247    /// forces a future variant to land in a fresh
1248    /// `(keeps_process, runs_reset_job)` bucket — or the author
1249    /// extends the consumer dispatch in
1250    /// `tatara-pool-reconciler::return_policy::plan_return` to
1251    /// recognize the new projection bucket.
1252    #[test]
1253    fn return_policy_predicate_pair_is_injective() {
1254        let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1255            .into_iter()
1256            .map(|p| (p.keeps_process(), p.runs_reset_job()))
1257            .collect();
1258        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1259        assert_eq!(
1260            projections.len(),
1261            unique.len(),
1262            "predicate pair projection is not injective: {projections:?}",
1263        );
1264    }
1265
1266    /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1267    /// the variant tagged `#[default]` in the enum, AND that variant
1268    /// is the safe "tear down + respawn" behavior — neither keeps the
1269    /// process nor runs a reset Job. A future `#[default]` rename
1270    /// without flipping the predicates fails here.
1271    #[test]
1272    fn return_policy_default_is_replace_and_neither_predicate_fires() {
1273        let d = ReturnPolicy::default();
1274        assert_eq!(d, ReturnPolicy::Replace);
1275        assert!(!d.keeps_process());
1276        assert!(!d.runs_reset_job());
1277    }
1278
1279    // ── closed-set algebra contracts for MemberState
1280    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1281
1282    /// Structural well-formedness of [`MemberState`] as a
1283    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1284    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1285    /// and [`return_policy_is_well_formed_closed_set`] above.
1286    #[test]
1287    fn member_state_is_well_formed_closed_set() {
1288        tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1289    }
1290
1291    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1292    /// output verbatim for every variant. A future variant rename (or
1293    /// an `as_str` arm typo) lands here at one site, instead of
1294    /// drifting between the typed surface, the CRD enum, and the YAML
1295    /// wire format the pool reconciler stamps on
1296    /// `status.members[].state`.
1297    #[test]
1298    fn member_state_as_str_matches_serde() {
1299        for state in MemberState::ALL {
1300            let serialized = serde_json::to_string(&state).expect("serialize");
1301            let unquoted = serialized
1302                .trim_start_matches('"')
1303                .trim_end_matches('"')
1304                .to_string();
1305            assert_eq!(
1306                unquoted,
1307                state.as_str(),
1308                "as_str drift for {state:?}: as_str={} serde={unquoted}",
1309                state.as_str()
1310            );
1311        }
1312    }
1313
1314    /// The Display impl IS `as_str` — pinning this lets future callers
1315    /// reach for either projection without drift. Any operator-facing
1316    /// "state={state}" diagnostic that composes through Display
1317    /// inherits the canonical wire-format string automatically.
1318    #[test]
1319    fn member_state_display_matches_as_str() {
1320        for state in MemberState::ALL {
1321            assert_eq!(state.to_string(), state.as_str());
1322        }
1323    }
1324
1325    /// `FromStr` rejects strings that aren't in the canonical
1326    /// projection — lowercased / typo / cross-axis-leaked — and
1327    /// the error echoes the input verbatim so the operator-facing
1328    /// diagnostic carries the offending value, not a normalized form.
1329    /// The empty-input arm is pinned by
1330    /// [`member_state_is_well_formed_closed_set`] via the
1331    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1332    /// pin the closed-set REJECTION contract that the trait can't see:
1333    /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1334    /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1335    /// `ProcessPhase`) but MUST reject here, because the codomains
1336    /// are disjoint.
1337    #[test]
1338    fn unknown_member_state_errors() {
1339        for bad in [
1340            "free",
1341            "SPAWNING",
1342            "Free-State",
1343            "allocated_now",
1344            "ReplaceImmediate", // ReplacementPolicy-axis leak
1345            "Reset",            // ReturnPolicy-axis leak
1346            "Attested",         // ProcessPhase-axis leak
1347        ] {
1348            let err = MemberState::from_str(bad).unwrap_err();
1349            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1350        }
1351    }
1352
1353    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1354    /// documented per-variant lifecycle role. The pool reconciler's
1355    /// `pool_phase_from_members` supply calc collapses
1356    /// `count_state(Free) + count_state(Spawning)` into one
1357    /// `counts_toward_supply` filter; this table pins the per-variant
1358    /// projection that consumer depends on.
1359    #[test]
1360    fn member_state_predicate_truth_tables() {
1361        assert!(!MemberState::Spawning.is_failed());
1362        assert!(MemberState::Spawning.counts_toward_supply());
1363
1364        assert!(!MemberState::Free.is_failed());
1365        assert!(MemberState::Free.counts_toward_supply());
1366
1367        assert!(!MemberState::Allocated.is_failed());
1368        assert!(!MemberState::Allocated.counts_toward_supply());
1369
1370        assert!(!MemberState::Returning.is_failed());
1371        assert!(!MemberState::Returning.counts_toward_supply());
1372
1373        assert!(MemberState::Failed.is_failed());
1374        assert!(!MemberState::Failed.counts_toward_supply());
1375    }
1376
1377    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1378    /// `is_failed` and `counts_toward_supply` simultaneously — a
1379    /// failed member can never be counted as available capacity. A
1380    /// future variant that returned true from both would FAIL here,
1381    /// forcing the author to either drop it from supply, or extend
1382    /// the consumer's bucketing in
1383    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1384    /// deliberately rather than silently inflating the pool's supply
1385    /// count with failed slots.
1386    #[test]
1387    fn member_state_failed_implies_no_supply() {
1388        for state in MemberState::ALL {
1389            assert!(
1390                !(state.is_failed() && state.counts_toward_supply()),
1391                "{state:?} returns true from both is_failed and counts_toward_supply — \
1392                 a failed member can never be counted as available pool capacity",
1393            );
1394        }
1395    }
1396
1397    /// COVERAGE CONTRACT: every variant lands somewhere — either
1398    /// in supply, or as a failed slot, or as an in-use bucket
1399    /// (`Allocated | Returning`). A future variant that returns
1400    /// `false` from `counts_toward_supply` AND `false` from
1401    /// `is_failed` is fine *iff* it represents an in-use slot; this
1402    /// test pins the existing variants in their declared buckets so
1403    /// the consumer-side dispatch in
1404    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1405    /// stays grounded.
1406    #[test]
1407    fn member_state_buckets_cover_every_variant() {
1408        let mut supply = 0u32;
1409        let mut failed = 0u32;
1410        let mut in_use = 0u32;
1411        for state in MemberState::ALL {
1412            match (state.is_failed(), state.counts_toward_supply()) {
1413                (true, false) => failed += 1,
1414                (false, true) => supply += 1,
1415                (false, false) => in_use += 1,
1416                (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1417            }
1418        }
1419        assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1420        assert_eq!(failed, 1, "failed bucket: Failed");
1421        assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1422        assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1423    }
1424
1425    // ── closed-set algebra contracts for PoolPhase
1426    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1427
1428    /// Structural well-formedness of [`PoolPhase`] as a
1429    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1430    /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1431    #[test]
1432    fn pool_phase_is_well_formed_closed_set() {
1433        tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1434    }
1435
1436    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1437    /// output verbatim for every variant. A future variant rename (or
1438    /// an `as_str` arm typo) lands here at one site, instead of
1439    /// drifting between the typed surface, the CRD enum, and the YAML
1440    /// wire format the pool reconciler stamps on `status.phase`.
1441    #[test]
1442    fn pool_phase_as_str_matches_serde() {
1443        for phase in PoolPhase::ALL {
1444            let serialized = serde_json::to_string(&phase).expect("serialize");
1445            let unquoted = serialized
1446                .trim_start_matches('"')
1447                .trim_end_matches('"')
1448                .to_string();
1449            assert_eq!(
1450                unquoted,
1451                phase.as_str(),
1452                "as_str drift for {phase:?}: as_str={} serde={unquoted}",
1453                phase.as_str()
1454            );
1455        }
1456    }
1457
1458    /// The Display impl IS `as_str` — pinning this lets future callers
1459    /// reach for either projection without drift. Any operator-facing
1460    /// "phase={phase}" diagnostic that composes through Display
1461    /// inherits the canonical wire-format string automatically.
1462    #[test]
1463    fn pool_phase_display_matches_as_str() {
1464        for phase in PoolPhase::ALL {
1465            assert_eq!(phase.to_string(), phase.as_str());
1466        }
1467    }
1468
1469    /// `FromStr` rejects strings that aren't in the canonical
1470    /// projection — lowercased / typo / cross-axis-leaked — and
1471    /// the error echoes the input verbatim so the operator-facing
1472    /// diagnostic carries the offending value, not a normalized form.
1473    /// The empty-input arm is pinned by
1474    /// [`pool_phase_is_well_formed_closed_set`] via the
1475    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1476    /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1477    /// closed-set REJECTION contract that the trait can't see — those
1478    /// are valid sibling-axis labels but MUST reject here.
1479    #[test]
1480    fn unknown_pool_phase_errors() {
1481        for bad in [
1482            "steady",
1483            "SCALINGUP",
1484            "Scaling-Up",
1485            "scaling_down",
1486            "Free",       // MemberState-axis leak
1487            "Replace",    // ReturnPolicy-axis leak
1488            "Attested",   // ProcessPhase-axis leak
1489            "HoldFailed", // ReplacementPolicy-axis leak
1490        ] {
1491            let err = PoolPhase::from_str(bad).unwrap_err();
1492            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1493        }
1494    }
1495
1496    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1497    /// documented per-variant lifecycle role. Pinning this table at
1498    /// one site means any future status-aggregator surface
1499    /// (`feira pool list --healthy`, the SSE filter, the desired-loop
1500    /// heartbeat short-circuit) reads the same projection that the
1501    /// reconciler writes.
1502    #[test]
1503    fn pool_phase_predicate_truth_tables() {
1504        assert!(!PoolPhase::Initializing.is_steady());
1505        assert!(!PoolPhase::Initializing.is_terminal());
1506
1507        assert!(PoolPhase::Steady.is_steady());
1508        assert!(!PoolPhase::Steady.is_terminal());
1509
1510        assert!(!PoolPhase::ScalingUp.is_steady());
1511        assert!(!PoolPhase::ScalingUp.is_terminal());
1512
1513        assert!(!PoolPhase::ScalingDown.is_steady());
1514        assert!(!PoolPhase::ScalingDown.is_terminal());
1515
1516        assert!(!PoolPhase::Degraded.is_steady());
1517        assert!(!PoolPhase::Degraded.is_terminal());
1518
1519        assert!(!PoolPhase::Draining.is_steady());
1520        assert!(PoolPhase::Draining.is_terminal());
1521    }
1522
1523    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1524    /// `is_steady` and `is_terminal` simultaneously — a draining pool
1525    /// is by definition transitioning OUT, not the goal converged
1526    /// state. A future variant that returned true from both would
1527    /// FAIL here, forcing the author to either pick one bucket or
1528    /// extend the consumer dispatch sites (status aggregators,
1529    /// heartbeat short-circuit) deliberately rather than silently
1530    /// double-firing both branches.
1531    #[test]
1532    fn pool_phase_steady_excludes_terminal() {
1533        for phase in PoolPhase::ALL {
1534            assert!(
1535                !(phase.is_steady() && phase.is_terminal()),
1536                "{phase:?} returns true from both is_steady and is_terminal — \
1537                 a draining pool is by definition not the converged goal state",
1538            );
1539        }
1540    }
1541
1542    /// COVERAGE CONTRACT: every variant lands somewhere — either the
1543    /// converged goal (`Steady`), the absorbing exit (`Draining`),
1544    /// or the transient bucket (`Initializing | ScalingUp |
1545    /// ScalingDown | Degraded` — pool is in motion toward desired).
1546    /// A future variant that returns `false` from BOTH predicates is
1547    /// fine *iff* it represents an in-motion state; this test pins
1548    /// the existing variants in their declared buckets so the
1549    /// projection consumers stay grounded.
1550    #[test]
1551    fn pool_phase_buckets_cover_every_variant() {
1552        let mut converged = 0u32;
1553        let mut terminal = 0u32;
1554        let mut transient = 0u32;
1555        for phase in PoolPhase::ALL {
1556            match (phase.is_steady(), phase.is_terminal()) {
1557                (true, false) => converged += 1,
1558                (false, true) => terminal += 1,
1559                (false, false) => transient += 1,
1560                (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
1561            }
1562        }
1563        assert_eq!(converged, 1, "converged bucket: Steady");
1564        assert_eq!(terminal, 1, "terminal bucket: Draining");
1565        assert_eq!(
1566            transient, 4,
1567            "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
1568        );
1569        assert_eq!(
1570            converged + terminal + transient,
1571            PoolPhase::ALL.len() as u32
1572        );
1573    }
1574
1575    /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
1576    /// variant a freshly-admitted pool should land in — `Initializing`
1577    /// — AND that variant is neither steady (no members yet) nor
1578    /// terminal (not deletion-stamped). A future `Default` rename
1579    /// without flipping the predicates fails here.
1580    #[test]
1581    fn pool_phase_default_is_initializing_in_transient_bucket() {
1582        let d = PoolPhase::default();
1583        assert_eq!(d, PoolPhase::Initializing);
1584        assert!(!d.is_steady());
1585        assert!(!d.is_terminal());
1586    }
1587}