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
147impl PoolSpec {
148    /// Humantime-parsed [`std::time::Duration`] projection of the
149    /// [`Self::free_ttl`] slot — the ONE-line collapse of the paired
150    /// `humantime::parse_duration(&<pool>.spec.free_ttl).ok()`
151    /// incantation the pool reconciler's stale-free bucket loop
152    /// hand-authored pre-lift, sibling to
153    /// [`crate::lifetime::EphemeralLifetime::ttl_duration`] on the
154    /// SAME `(humantime string field × Option<Duration>) → Option<
155    /// Duration>` substrate axis.
156    ///
157    /// Pre-lift the `humantime::parse_duration(&<field>).ok()` shape
158    /// was owned at ONE substrate primitive on
159    /// [`crate::lifetime::EphemeralLifetime`] (the `spec.lifetime
160    /// .ephemeral.ttl` axis, feeding
161    /// [`crate::lifetime_clock::evaluate`]'s TTL-expiry gate + the
162    /// `requeue_with_ttl` sleep-budget picker) AND hand-authored at
163    /// ONE peer consumer site — `tatara-pool-reconciler::pool_decide
164    /// ::decide_pool`, which parses `pool.spec.free_ttl` with the
165    /// byte-identical shape (`humantime::parse_duration(&spec
166    /// .free_ttl).unwrap_or_default()`) and gates the stale-free
167    /// bucket loop on the result. That's ONE substrate owner + ONE
168    /// hand-authored chain on a peer humantime field of a peer spec
169    /// type past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger —
170    /// two surfaces spelling the SAME projection with the SAME drift
171    /// risk (a per-fleet minimum TTL floor before the humantime cast,
172    /// a canonical unit-normalization pass, a warn-log on
173    /// unparseable strings would have had to land at every surface
174    /// plus stay coherent between them).
175    ///
176    /// Post-lift both peer humantime fields
177    /// ([`crate::lifetime::EphemeralLifetime::ttl`] +
178    /// [`Self::free_ttl`]) publish the SAME shape at TWO peer
179    /// inherent methods on peer spec types — the tatara-pool-
180    /// reconciler's stale-free bucket loop reads `pool.spec.free_ttl
181    /// _duration().unwrap_or_default()` and the produced [`std::time
182    /// ::Duration`] feeds the same `!free_ttl.is_zero()` guard +
183    /// `elapsed > free_ttl` comparator unchanged. A future
184    /// normalization (per-fleet minimum floor, canonical unit
185    /// normalization, warn-log on unparseable strings) lands at TWO
186    /// substrate methods here + on
187    /// [`crate::lifetime::EphemeralLifetime::ttl_duration`], reachable
188    /// via ONE workspace-wide sweep across the peer axis rather than
189    /// as a per-callsite hand-edit at every downstream humantime-ttl
190    /// consumer.
191    ///
192    /// Return-form axis: `Option<std::time::Duration>` matches the
193    /// peer primitive on
194    /// [`crate::lifetime::EphemeralLifetime::ttl_duration`] and the
195    /// downstream comparator's type. The peer projection
196    /// [`crate::time::elapsed_since`] returns the SAME `Option<std
197    /// ::time::Duration>` shape, so the stale-free gate's `elapsed >
198    /// free_ttl` comparator lands with both operands on the same
199    /// axis without a per-consumer conversion step.
200    ///
201    /// The `None` arm is the "operator's `free_ttl` string doesn't
202    /// parse" corner — a typo (`"1our"`), an unsupported unit, a
203    /// non-humantime literal that reached the field. The pool
204    /// reconciler's stale-free bucket loop collapses the corner via
205    /// `.unwrap_or_default()`, yielding the `Duration::ZERO` value
206    /// that already gates its follow-on `!free_ttl.is_zero()` check
207    /// — post-lift semantics is byte-identical to the pre-lift
208    /// hand-authored `humantime::parse_duration(&spec.free_ttl)
209    /// .unwrap_or_default()` shape.
210    ///
211    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
212    /// the `humantime::parse_duration(&<field>).ok()` shape recurred
213    /// at ONE substrate owner + ONE hand-authored peer site past the
214    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted onto
215    /// TWO peer inherent methods on peer spec types here + on
216    /// [`crate::lifetime::EphemeralLifetime::ttl_duration`]).
217    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
218    /// the pins below bind the parse-failure corner, the empty-ttl
219    /// corner, the humantime edge shapes, the return-form parity with
220    /// [`crate::lifetime::EphemeralLifetime::ttl_duration`], and the
221    /// byte-identical parity with the pre-lift `.ok()` chain on the
222    /// SAME `spec.free_ttl` value, so a regression that drifts any
223    /// surface fails at `tests::pool_spec_free_ttl_duration_*` here
224    /// rather than as silent operator-facing skew between the pool
225    /// stale-free bucket loop and the ephemeral TTL-expiry gate on
226    /// the two peer humantime-string fields).
227    #[must_use]
228    pub fn free_ttl_duration(&self) -> Option<std::time::Duration> {
229        humantime::parse_duration(&self.free_ttl).ok()
230    }
231}
232
233impl EphemeralPool {
234    /// Borrow-form metadata-projection primitive on the `metadata.name`
235    /// axis of `EphemeralPool`: returns the K8s object name slice with
236    /// the missing-name corner collapsed to the load-bearing empty-string
237    /// sentinel — the ONE-liner collapse of the paired
238    /// `self.metadata.name.as_deref().unwrap_or("")` incantation every
239    /// pool-side consumer restated by hand pre-lift.
240    ///
241    /// Pre-lift the `.metadata.name.as_deref().unwrap_or("")` chain
242    /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
243    /// duplication threshold in `tatara-pool-reconciler`, both keyed
244    /// by the pool's own name slot:
245    /// * `router::pool_name` — the tie-break comparator inside
246    ///   `best_match`; a deterministic lexicographic-min-name arbiter
247    ///   across two pool candidates whose specificity scores tie.
248    /// * `controller_allocation::reconcile_inner` — the `HashMap<
249    ///   pool-name, Vec<PoolMember>>` lookup closure fed into
250    ///   `decide_allocation_reconcile`; keys the "which pool members
251    ///   back this allocation candidate?" projection at every
252    ///   allocation-reconcile pass.
253    ///
254    /// Both sites walked the SAME `.as_deref().unwrap_or("")` chain
255    /// and both wanted the `&str` form the primitive returns — as a
256    /// borrow suitable for lexicographic `str::cmp` in the tie-break
257    /// AND for the `HashMap<String, _>::get(&str)` lookup. Post-lift
258    /// each caller reaches for `pool.name_or_empty()` and the produced
259    /// slice feeds the same downstream comparator / lookup unchanged.
260    ///
261    /// The empty-string fallback is the SAME sentinel the sibling
262    /// borrow-form primitive [`crate::crd::Process::uid_or_empty`]
263    /// returns AND the SAME sentinel the owned-form sibling
264    /// [`crate::crd::Process::owned_name_or_empty`] returns on the
265    /// `metadata.name` axis of the sister CRD — the three primitives
266    /// partition the (borrow-form × owned-form) × (uid × name) corner
267    /// of the metadata-slot family on identical fallback semantics
268    /// (empty string means "the slot is unset"), so a consumer that
269    /// switches between the CRD surfaces based on downstream keying
270    /// requirements never sees a different missing-slot spelling as
271    /// a side effect.
272    ///
273    /// Return-form axis: `&str` mirrors the borrow-first discipline
274    /// of the peer metadata primitives on `Process`
275    /// ([`crate::crd::Process::namespace_or_default`],
276    /// [`crate::crd::Process::name_or_placeholder`],
277    /// [`crate::crd::Process::uid_or_empty`]). The one missing-slot
278    /// corner the chain swallowed pre-lift (missing `metadata.name`)
279    /// collapses to the empty-string sentinel so `str::is_empty` /
280    /// `HashMap::get` on an unnamed pool behaves identically to what
281    /// the pre-lift `.as_deref().unwrap_or("")` chain produced.
282    ///
283    /// A future normalization step (a name-canonicalization pass, a
284    /// case-fold key builder, a per-cluster prefix stripper for
285    /// cross-cluster pool-name aliasing) lands at ONE substrate
286    /// method here and both downstream consumers pick up the upgrade
287    /// mechanically — no per-callsite hand-edit at `pool_name` /
288    /// `reconcile_inner`.
289    ///
290    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
291    /// the `.metadata.name.as_deref().unwrap_or("")` chain recurred
292    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
293    /// duplication trigger, and is lifted to ONE owner here).
294    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
295    /// the pins bind the missing-name corner + the empty-string
296    /// sentinel byte-shape + the borrow-form `&str` lifetime + the
297    /// byte-identical parity with the pre-lift chain + the fallback-
298    /// value coherence with `Process::uid_or_empty` /
299    /// `Process::owned_name_or_empty` on the metadata-slot × empty-
300    /// sentinel axis, so a regression that drifted any surface at
301    /// `tests::name_or_empty_*` here rather than as silent operator-
302    /// facing skew between the router tie-break and the allocation
303    /// member-lookup on the SAME pool candidate).
304    pub fn name_or_empty(&self) -> &str {
305        self.metadata.name.as_deref().unwrap_or("")
306    }
307
308    /// Owned-form metadata-projection primitive on the `metadata.name`
309    /// axis of `EphemeralPool`: returns an owned `String` copy of the K8s
310    /// object name with the missing-name corner collapsed to the load-
311    /// bearing empty-string sentinel — the ONE-liner collapse of the
312    /// paired `self.metadata.name.clone().unwrap_or_default()` incantation
313    /// every pool-side consumer restated by hand pre-lift.
314    ///
315    /// Pre-lift the `.metadata.name.clone().unwrap_or_default()` chain
316    /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
317    /// duplication threshold in `tatara-pool-reconciler`, both keyed by
318    /// the pool's own name slot in an `owned String` context:
319    /// * `controller_allocation::reconcile_inner` — the
320    ///   `HashMap<String, Vec<PoolMember>>` key seed inside a
321    ///   `pools.iter().map(|p| ...).collect()` fanout; the map key is
322    ///   the owned `String` form because the produced `HashMap<String, _>`
323    ///   outlives the pool-list borrow that generated it and the
324    ///   downstream `pool_members.get(pool.name_or_empty())` closure
325    ///   consumes it as `&str`.
326    /// * `allocation_decide::AllocationConvergenceCtx::observe` — the
327    ///   `AllocationRef::name` slot seed stamped on the matched-pool
328    ///   handle; the struct literal is `AllocationRef { name: String,
329    ///   namespace: String }` and the produced value is threaded through
330    ///   the `Decision::decide` transition rule downstream.
331    ///
332    /// Both sites walked the SAME `.clone().unwrap_or_default()` chain
333    /// and both wanted the `String` form the primitive returns — as the
334    /// owned key of a `HashMap<String, _>` and as the `String` slot of
335    /// an `AllocationRef` struct literal. Post-lift each callsite reads
336    /// `pool.owned_name_or_empty()` and the produced value feeds the
337    /// same downstream key / struct-literal slot unchanged.
338    ///
339    /// The empty-string fallback is the SAME sentinel the sibling
340    /// borrow-form primitive [`Self::name_or_empty`] returns AND the
341    /// SAME sentinel the sibling owned-form primitive
342    /// [`crate::crd::Process::owned_name_or_empty`] returns on the
343    /// `metadata.name` axis of the sister CRD — the three primitives
344    /// partition the (borrow-form × owned-form) corner of the metadata-
345    /// name family across BOTH tatara-process CRDs on identical missing-
346    /// slot semantics (empty string means "the slot is unset"), so a
347    /// consumer that switches between the CRD surfaces based on
348    /// downstream ownership requirements never sees a different
349    /// missing-slot spelling as a side effect.
350    ///
351    /// Peer to [`Self::name_or_empty`] on the (return-form × ownership)
352    /// axis pair — closes the corner the pool-side family previously
353    /// left open:
354    ///
355    /// * borrow + empty sentinel → [`Self::name_or_empty`] (router tie-
356    ///   break comparator, `HashMap<String, _>::get(&str)` lookup —
357    ///   consumers whose downstream keys by `&str` and allocates
358    ///   nothing);
359    /// * owned + empty sentinel → **this method** (HashMap-key seed in
360    ///   an outliving-borrow context, `AllocationRef::name` struct-
361    ///   literal slot — consumers whose downstream requires the owned
362    ///   `String` form because the produced value outlives the source-
363    ///   pool borrow).
364    ///
365    /// A future normalization step (a name-canonicalization pass, a
366    /// case-fold key builder, a per-cluster prefix stripper for cross-
367    /// cluster pool-name aliasing) lands at ONE substrate method here
368    /// and both downstream consumers pick up the upgrade mechanically —
369    /// no per-callsite hand-edit at `reconcile_inner` /
370    /// `AllocationConvergenceCtx::observe`.
371    ///
372    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
373    /// the `.metadata.name.clone().unwrap_or_default()` chain recurred
374    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
375    /// duplication trigger, and is lifted to ONE owner here).
376    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
377    /// the pins bind the missing-name corner + the empty-string
378    /// sentinel byte-shape + the owned-form `String` return type + the
379    /// byte-identical parity with the pre-lift chain + the fallback-
380    /// value coherence with [`Self::name_or_empty`] +
381    /// [`crate::crd::Process::owned_name_or_empty`] on the metadata-
382    /// slot × empty-sentinel axis, so a regression that drifted any
383    /// surface at `tests::owned_name_or_empty_*` here rather than as
384    /// silent operator-facing skew between the pool-members lookup key
385    /// and the AllocationRef seed on the SAME pool candidate).
386    pub fn owned_name_or_empty(&self) -> String {
387        self.metadata.name.clone().unwrap_or_default()
388    }
389
390    /// Copy-form metadata-projection primitive on the deletion-tombstone
391    /// axis of `EphemeralPool`: returns `true` iff the K8s API server
392    /// has stamped a `metadata.deletionTimestamp` on this pool (the
393    /// moment the object entered the "being deleted" corner of its
394    /// lifecycle, after which further mutating writes are refused and
395    /// finalizers are drained before the object is actually removed) —
396    /// the ONE-liner collapse of the paired
397    /// `self.metadata.deletion_timestamp.is_some()` incantation every
398    /// pool-side consumer restated by hand pre-lift.
399    ///
400    /// Pre-lift the `.metadata.deletion_timestamp.is_some()` chain was
401    /// hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
402    /// duplication threshold in `tatara-pool-reconciler`, both
403    /// projecting the SAME tombstone-presence predicate on an
404    /// `EphemeralPool` value:
405    /// * `pool_decide::decide_pool_reconcile` — the pure decision
406    ///   function's deletion-preempt gate that forces
407    ///   [`PoolDecision::Drain`] as soon as the API server stamps
408    ///   the tombstone, before the (desired vs actual) supply-arithmetic
409    ///   branches get a chance to run. Wired at the very top of the
410    ///   decision so a draining pool never spawns / reaps / expires
411    ///   through the normal replenishment arithmetic while the
412    ///   deletion is in flight.
413    /// * `controller_pool::pool_phase_from_members` — the observed-
414    ///   phase composer's tombstone-first arm that returns
415    ///   [`PoolPhase::Draining`] regardless of the supply / demand
416    ///   arithmetic that would otherwise pick `Ready` / `Scaling` /
417    ///   `Degraded`. Keeps the reported phase honest during the
418    ///   finalizer drain so operators reading `kubectl get
419    ///   ephemeralpools` see the tombstone-present state as
420    ///   `Draining`, not as a stale `Ready`.
421    ///
422    /// Both sites walked the SAME `.metadata.deletion_timestamp
423    /// .is_some()` chain and both wanted the `bool` form the primitive
424    /// returns — the `decide_pool_reconcile` site to gate the
425    /// `→ Drain` short-circuit and the `pool_phase_from_members` site
426    /// to gate the `→ Draining` short-circuit. Post-lift each callsite
427    /// reads `pool.is_being_deleted()` and the produced `bool` feeds
428    /// the same downstream short-circuit unchanged.
429    ///
430    /// Sibling to [`crate::crd::Process::is_being_deleted`] on the
431    /// deletion-tombstone axis of the sister CRD — the two primitives
432    /// now partition the tombstone-presence probe across BOTH
433    /// tatara-process CRDs on identical missing-slot semantics
434    /// (present timestamp means "the API server has begun deletion"),
435    /// so an operator or reconciler that switches between the CRD
436    /// surfaces never sees a different tombstone-detection spelling
437    /// as a side effect.
438    ///
439    /// Return-form axis: `bool` matches the copy-form discipline of
440    /// the sibling [`crate::crd::Process::is_being_deleted`] and of
441    /// the pool-side [`crate::phase::ProcessPhase::is_alive`] +
442    /// [`Self::name_or_empty`]-family primitives — the underlying
443    /// slot is a wire-format `Option<Time>` that carries only
444    /// presence information at this axis (the RFC-3339 timestamp
445    /// payload itself is not what the two consumers read; both only
446    /// probe presence to detect the tombstone-stamped state).
447    /// Returning the raw `Option<&Time>` would push the `.is_some()`
448    /// probe back to every callsite, restating the pre-lift chain
449    /// one link shorter without collapsing the primitive.
450    ///
451    /// Peer to [`Self::name_or_empty`] and [`Self::owned_name_or_empty`]
452    /// on the metadata-projection axis for `EphemeralPool`; this method
453    /// opens the presence-probe corner for the tombstone slot. Future
454    /// metadata-presence projections on the pool CRD (an
455    /// `is_being_finalized` projection on
456    /// `metadata.finalizers.is_empty()`'s negation, a `has_owner`
457    /// projection on `metadata.owner_references.is_empty()`'s
458    /// negation) land as peer methods on this same axis.
459    ///
460    /// A future normalization step (a per-tombstone staleness gate
461    /// that returns `false` for a tombstone older than the reconciler's
462    /// grace-period budget, a canonicalization pass that treats a
463    /// tombstone from a paused controller as absent, a cross-cluster
464    /// tombstone-observation clock skew guard) lands at ONE substrate
465    /// method here and both downstream consumers pick up the upgrade
466    /// mechanically — no per-callsite hand-edit at
467    /// `decide_pool_reconcile` / `pool_phase_from_members`.
468    ///
469    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
470    /// the `.metadata.deletion_timestamp.is_some()` chain recurred at
471    /// two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
472    /// duplication trigger, and is lifted to ONE owner here).
473    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
474    /// the pins bind the missing-tombstone corner + the present-
475    /// tombstone corner + the copy-form `bool` return + the byte-
476    /// identical parity with the pre-lift `.is_some()` chain + the
477    /// cross-CRD coherence with `crate::crd::Process::is_being_deleted`
478    /// on the tombstone axis, so a regression that drifted any surface
479    /// at `tests::is_being_deleted_*` rather than as silent operator-
480    /// facing skew between the pool-reconciler's `→ Drain` decision
481    /// and the observed-phase composer's `→ Draining` report on the
482    /// SAME `EphemeralPool` within one reconcile pass).
483    pub fn is_being_deleted(&self) -> bool {
484        self.metadata.deletion_timestamp.is_some()
485    }
486
487    /// Owned-form metadata-projection primitive on the `metadata.namespace`
488    /// axis of `EphemeralPool`: returns an owned `String` copy of the K8s
489    /// namespace with the missing-namespace corner collapsed to the load-
490    /// bearing empty-string sentinel — the ONE-liner collapse of the
491    /// paired `self.metadata.namespace.clone().unwrap_or_default()`
492    /// incantation every pool-side consumer restated by hand pre-lift.
493    ///
494    /// Pre-lift the `.metadata.namespace.clone().unwrap_or_default()`
495    /// chain was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE
496    /// ≥ 2 duplication threshold, both stamping the `AllocationRef
497    /// { namespace: String, .. }` slot inside an owned-`String` context:
498    /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx
499    ///   ::observe` — the matched-pool seed's `AllocationRef.namespace`
500    ///   slot, right beside the peer [`Self::owned_name_or_empty`] call
501    ///   that owns the paired name half. This is the exact site the
502    ///   pre-existing peer-primitive doc-comment forecast (`"a future
503    ///   run may lift owned_namespace_or_empty as the sibling axis
504    ///   peer"`).
505    /// * `crate::pool::tests::allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`
506    ///   — the composition pin that seeded an `AllocationRef` from the
507    ///   same paired-primitive-half construction the production consumer
508    ///   in `allocation_decide::observe` performs. Post-lift the pin
509    ///   composes two peer primitives (`owned_name_or_empty` +
510    ///   `owned_namespace_or_empty`) rather than one primitive plus the
511    ///   pre-lift chain, sharpening it from a mixed-form composition
512    ///   check into a paired-primitive-family composition check.
513    ///
514    /// Both sites walked the SAME `.clone().unwrap_or_default()` chain
515    /// and both wanted the `String` form the primitive returns — as the
516    /// `String` slot of an `AllocationRef` struct literal built through
517    /// [`crate::pool::AllocationRef::new`]. Post-lift each callsite reads
518    /// `pool.owned_namespace_or_empty()` and the produced value feeds
519    /// the same downstream `AllocationRef` slot unchanged.
520    ///
521    /// The empty-string fallback is the SAME sentinel the sibling owned-
522    /// form primitive [`Self::owned_name_or_empty`] returns on the
523    /// `metadata.name` axis of the same CRD — the two primitives now
524    /// partition the (owned `String` × `metadata.<slot>`) corner of the
525    /// pool CRD's metadata family across BOTH object-coordinate slots
526    /// on identical missing-slot semantics (empty string means "the
527    /// slot is unset"), so the [`crate::pool::AllocationRef::new`]
528    /// composer sees a coherent owned-empty pair regardless of which
529    /// slot is absent on the source pool. Coherent with the workspace-
530    /// wide owned-empty sentinel that the peer primitives
531    /// [`crate::crd::Process::uid_or_empty`],
532    /// [`crate::crd::Process::owned_name_or_empty`],
533    /// [`Self::name_or_empty`], and [`Self::owned_name_or_empty`]
534    /// already share on the metadata-slot × empty-sentinel axis.
535    ///
536    /// Peer to [`Self::owned_name_or_empty`] on the
537    /// (`metadata.name` × `metadata.namespace`) axis of the owned-form
538    /// projection family — closes the corner the pool-side family
539    /// previously left open:
540    ///
541    /// * owned + name + empty sentinel → [`Self::owned_name_or_empty`]
542    ///   (`AllocationRef.name` seed, `HashMap<String, _>` key seed);
543    /// * owned + namespace + empty sentinel → **this method**
544    ///   (`AllocationRef.namespace` seed — the paired half the same
545    ///   `AllocationRef::new(name, namespace)` constructor consumes);
546    /// * copy + deletion + tombstone probe → [`Self::is_being_deleted`]
547    ///   (the presence-probe corner of the same metadata axis, already
548    ///   opened).
549    ///
550    /// A future normalization step (a namespace-canonicalization pass,
551    /// a case-fold key builder, a per-cluster prefix stripper, or the
552    /// canonical-namespace default lift that would substitute
553    /// [`crate::crd::Process::DEFAULT_NAMESPACE`] on the missing-slot
554    /// corner rather than the empty-string sentinel) lands at ONE
555    /// substrate method here and both downstream consumers pick up the
556    /// upgrade mechanically — no per-callsite hand-edit at
557    /// `AllocationConvergenceCtx::observe` / the composition pin.
558    ///
559    /// The empty-string fallback (rather than
560    /// [`crate::crd::Process::DEFAULT_NAMESPACE`]) is DELIBERATELY
561    /// pinned: the sole downstream consumer
562    /// (`AllocationConvergenceCtx::observe`'s matched-pool seed) feeds
563    /// the produced value into `AllocationRef.namespace`, which is then
564    /// matched byte-identically against `spec.pool_ref.namespace` at
565    /// [`crate::pool::allocation_decide::resolve_pool`]-style comparators.
566    /// A silent substitution of `"default"` at this primitive would
567    /// alias every namespace-absent pool to the `"default"` bucket at
568    /// the matcher, hiding the missing-slot corner from an operator
569    /// who explicitly authored an allocation against a namespace-
570    /// unset pool. The load-bearing empty-string sentinel keeps the
571    /// pre-lift `.clone().unwrap_or_default()` shape verbatim so the
572    /// downstream matcher's byte-comparison stays honest.
573    ///
574    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
575    /// the `.metadata.namespace.clone().unwrap_or_default()` chain
576    /// recurred at two hand-authored sites past the ★★ PRIME-DIRECTIVE
577    /// ≥ 2 duplication trigger, and is lifted to ONE owner here).
578    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
579    /// the pins bind the missing-namespace corner + the empty-string
580    /// sentinel byte-shape + the owned-form `String` return type + the
581    /// byte-identical parity with the pre-lift chain + the fallback-
582    /// value coherence with [`Self::owned_name_or_empty`] on the
583    /// paired-slot axis, so a regression that drifted any surface at
584    /// `tests::owned_namespace_or_empty_*` rather than as silent
585    /// operator-facing skew between the paired name / namespace halves
586    /// of the SAME `AllocationRef` seed).
587    pub fn owned_namespace_or_empty(&self) -> String {
588        self.metadata.namespace.clone().unwrap_or_default()
589    }
590
591    /// Compound owned-form metadata-projection primitive on the paired
592    /// `(metadata.uid, metadata.name)` axis of `EphemeralPool`: returns
593    /// a stable owned `String` seed for slot-slug derivation, PREFERRING
594    /// the K8s-assigned uid, FALLING BACK to the pool's own name, then
595    /// SINKING to the load-bearing empty-string sentinel when both slots
596    /// are absent — the ONE-liner collapse of the paired
597    /// `pool.metadata.uid.clone().unwrap_or_else(|| name.<into>())`
598    /// incantation every pool-slot-name-composing consumer restated by
599    /// hand pre-lift.
600    ///
601    /// Pre-lift the `.metadata.uid.clone().unwrap_or_else(|| name.<into>())`
602    /// chain was hand-authored at TWO production sites past the ★★
603    /// PRIME-DIRECTIVE ≥ 2 duplication threshold in
604    /// `tatara-pool-reconciler::controller_pool`, both feeding the SAME
605    /// `member_process_name(&pool_name, &pool_uid_or_name_fallback, slot)`
606    /// composer:
607    /// * `reconcile_inner` — the desired-count `PoolDecision::Spawn`
608    ///   arm's spawn-loop slot-slug seed (fallback bound as
609    ///   `|| name.clone()` from the extracted-earlier owned `name`
610    ///   half of `owned_coordinates_required()`).
611    /// * `apply_convergence_actions` — the legacy allocation-driven
612    ///   `ConvergenceAction::CreateMember` arm's slot-slug seed
613    ///   (fallback bound as `|| name.to_string()` from the borrowed
614    ///   `name: &str` parameter that the same
615    ///   `owned_coordinates_required()`-extracted `String` was passed
616    ///   through by reference).
617    ///
618    /// Both sites computed the SAME "prefer the k8s uid; fall back to
619    /// the pool's own name" projection on the SAME `EphemeralPool`
620    /// value, differing only in the surface syntax of the fallback
621    /// (`.clone()` vs `.to_string()`) — a per-callsite typing artefact
622    /// of the enclosing scope's `name` binding rather than a semantic
623    /// distinction. Post-lift each callsite reads
624    /// `pool.owned_uid_or_name_or_empty()` and the produced owned
625    /// `String` feeds the same `member_process_name(&name, &_, slot)`
626    /// composer verbatim; the caller no longer threads its own local
627    /// `name` handle through as the fallback, since the primitive
628    /// reaches through the same `self.metadata.name` slot the caller
629    /// extracted from earlier — coherent by construction with the
630    /// sibling primitive [`Self::owned_name_or_empty`] on the missing-
631    /// name corner.
632    ///
633    /// The compound (uid-preferred, name-fallback, empty-sentinel)
634    /// precedence is DELIBERATELY pinned: the K8s API server stamps
635    /// `metadata.uid` on every persisted object at admission time, so
636    /// the reachable state at both callsites (each already gated by
637    /// `owned_coordinates_required()?`) has `uid = Some(_)`. The name
638    /// fallback is a load-bearing safety net for the vanishingly rare
639    /// pre-admission-uid corner + the unit-test path that constructs
640    /// an `EphemeralPool` value in-memory without stamping a uid; the
641    /// empty-string sink is the sentinel-coherent complement of the
642    /// missing-both corner (both slots `None`) so a regression that
643    /// dropped either fallback surfaces as a compiler-visible test
644    /// failure rather than as an operator-facing skew between spawn
645    /// slots derived from mixed-fallback seeds within one reconcile
646    /// pass. Coherent with the workspace-wide owned-empty sentinel
647    /// that the peer primitives [`Self::owned_name_or_empty`],
648    /// [`Self::owned_namespace_or_empty`],
649    /// [`crate::crd::Process::owned_name_or_empty`], and
650    /// [`crate::crd::Process::uid_or_empty`] already share on the
651    /// metadata-slot × empty-sentinel axis.
652    ///
653    /// A future normalization step (a per-cluster uid-prefix stripper,
654    /// a case-fold key builder, canonicalization of a suspiciously-
655    /// empty uid to the name fallback, a namespace-scoped hashing pass
656    /// that mixes cluster identity into the seed) lands at ONE
657    /// substrate method here and both downstream `spawn` /
658    /// `apply_convergence_actions` consumers pick up the upgrade
659    /// mechanically — no per-callsite hand-edit at `controller_pool`.
660    ///
661    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
662    /// the `.metadata.uid.clone().unwrap_or_else(|| name.<into>())`
663    /// chain recurred at two hand-authored sites past the ★★ PRIME-
664    /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
665    /// here). THEORY.md §II.1 invariant 5 (composition preserves
666    /// proofs — the pins bind the uid-present corner + the uid-absent
667    /// name-fallback corner + the both-absent empty-sentinel corner +
668    /// the owned-form `String` return type + the byte-identical parity
669    /// with each pre-lift callsite's fallback surface, so a regression
670    /// that drifted any surface at `tests::owned_uid_or_name_or_empty_*`
671    /// rather than as silent operator-facing skew between the two
672    /// slot-slug seeds within ONE reconcile pass).
673    pub fn owned_uid_or_name_or_empty(&self) -> String {
674        self.metadata
675            .uid
676            .clone()
677            .unwrap_or_else(|| self.owned_name_or_empty())
678    }
679
680    /// Copy-form metadata-projection primitive on the `metadata.name`
681    /// axis of `EphemeralPool` in its `presence-and-equal` corner:
682    /// returns `true` iff the K8s object name slot is BOTH `Some(_)`
683    /// AND byte-identical to the supplied candidate — the ONE-liner
684    /// collapse of the paired
685    /// `self.metadata.name.as_deref() == Some(candidate)` incantation
686    /// every pool-side lookup consumer restated by hand pre-lift.
687    ///
688    /// Pre-lift the `.metadata.name.as_deref() == Some(<candidate>)`
689    /// chain was hand-authored at TWO production sites past the ★★
690    /// PRIME-DIRECTIVE ≥ 2 duplication threshold in
691    /// `tatara-pool-reconciler`, both keyed by the `EphemeralPool`'s
692    /// own name slot inside a `candidate_pools.iter().find(|p| ...)`
693    /// closure that resolves a pool from an `AllocationRef.name` half:
694    /// * `allocation_decide::resolve_pool` — the explicit-`pool_ref`
695    ///   half of the pool-resolution ladder, one of two conjuncts in
696    ///   the `(name == X && namespace == Y)` byte-comparison against
697    ///   `AllocationSpec::pool_ref`. Pairs with the sibling namespace
698    ///   comparison (a future run may lift `has_namespace` as the
699    ///   paired-axis peer once a second namespace-probe site opens).
700    /// * `controller_allocation::reconcile_inner` — the TTL-inheritance
701    ///   fallback path's pool-lookup by `AllocationDecision::Bind::pool
702    ///   .name`, feeding the matched pool's `spec.template.ttl` into
703    ///   the just-bound member Process's lifetime overlay.
704    ///
705    /// Both sites walked the SAME `.as_deref() == Some(<x>.as_str())`
706    /// chain against a `&str` candidate held by an [`AllocationRef`]
707    /// or a similar owned-name handle, and both wanted the `bool`
708    /// form the primitive returns — the transition rule's discriminant
709    /// on either the `find(|p| p.has_name(&pool_ref.name))` closure
710    /// (which either matches ONE candidate pool or none) or the
711    /// TTL-inheritance closure's short-circuit through
712    /// `.map(...).unwrap_or_else(...)`. Post-lift each callsite reads
713    /// `p.has_name(&candidate)` and the produced `bool` feeds the same
714    /// downstream `find` / `map` closure unchanged.
715    ///
716    /// Distinct in semantics from the sibling primitive
717    /// [`Self::name_or_empty`] on the SAME `metadata.name` axis: the
718    /// `_or_empty` family folds the missing-slot corner to the load-
719    /// bearing empty-string sentinel (so `None` and `Some("")` both
720    /// project to `""`), whereas this primitive keeps `None` distinct
721    /// from `Some("")` at the `==` operator — a `None` slot returns
722    /// `false` even when the candidate is the empty string. That
723    /// discipline is load-bearing at both consumer sites: pre-lift
724    /// they compared `Option<&str>` against `Some(<candidate>)`, so a
725    /// substitution through `Self::name_or_empty` would silently
726    /// promote a namespace-absent pool with a `""` candidate into a
727    /// spurious match at the `find` closure, aliasing every unnamed
728    /// pool to the same lookup bucket at the resolver. Preserving the
729    /// `None ⇒ false` corner keeps the resolver's byte-comparison
730    /// honest.
731    ///
732    /// Peer to the sibling substrate primitives already opened on the
733    /// pool-side (`metadata.name` × return-form) axis:
734    /// * borrow-form + empty sentinel → [`Self::name_or_empty`] (`&str`
735    ///   projection with a `""` fallback for missing / explicitly-empty
736    ///   name slots; router tie-break comparator);
737    /// * owned-form + empty sentinel → [`Self::owned_name_or_empty`]
738    ///   (`String` projection with a `""` fallback; `AllocationRef.name`
739    ///   seed);
740    /// * **presence-and-equal probe → this method** (`bool` projection
741    ///   with `None`-preserving semantics; pool-lookup closure
742    ///   discriminant).
743    ///
744    /// A future normalization step (a name-canonicalization pass, a
745    /// case-fold key builder, a per-cluster prefix stripper for cross-
746    /// cluster pool-name aliasing, or a canonical-namespace default
747    /// lift) lands at ONE substrate method here and both downstream
748    /// consumers pick up the upgrade mechanically — no per-callsite
749    /// hand-edit at `resolve_pool` / `controller_allocation
750    /// ::reconcile_inner`.
751    ///
752    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
753    /// the `.metadata.name.as_deref() == Some(<candidate>)` chain
754    /// recurred at two hand-authored sites past the ★★ PRIME-
755    /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
756    /// here). THEORY.md §II.1 invariant 5 (composition preserves
757    /// proofs — the pins bind the missing-slot corner (`None ⇒
758    /// false`, even against a `""` candidate) + the populated-slot
759    /// equal corner + the populated-slot unequal corner + the
760    /// byte-identical parity with the pre-lift `.as_deref() == Some
761    /// (<candidate>)` chain + the disjoint semantics vs. the
762    /// `_or_empty` sibling family, so a regression that drifted any
763    /// surface at `tests::has_name_*` here rather than as silent
764    /// operator-facing skew between the two `find` closures the
765    /// primitive owns).
766    #[must_use]
767    pub fn has_name(&self, candidate: &str) -> bool {
768        self.metadata.name.as_deref() == Some(candidate)
769    }
770}
771
772/// What the pool reconciler does when a member reaches `Failed`.
773///
774/// Sibling closed-set lifts on the same `tatara-process` axis:
775/// [`crate::compliance::VerificationPhase::ALL`],
776/// [`crate::signal::SighupStrategy::ALL`],
777/// [`crate::spec::MustReachPhase::ALL`],
778/// [`crate::intent::WorkloadKind::ALL`],
779/// [`crate::export::ReportFormat::ALL`],
780/// [`crate::encapsulates::EncapsulationMode::ALL`],
781/// [`crate::export::ExportTrigger::ALL`],
782/// [`crate::lifetime::TeardownPolicy::ALL`],
783/// [`crate::boundary::ConditionKind::ALL`],
784/// [`crate::lifetime::LifetimeKind::ALL`],
785/// [`crate::intent::IntentKind::ALL`],
786/// [`crate::phase::ProcessPhase::ALL`],
787/// [`crate::signal::ProcessSignal::ALL`].
788#[derive(
789    Clone,
790    Copy,
791    Debug,
792    Default,
793    Serialize,
794    Deserialize,
795    JsonSchema,
796    PartialEq,
797    Eq,
798    Hash,
799    tatara_closed_set::DeriveClosedSet,
800)]
801#[serde(rename_all = "PascalCase")]
802#[closed_set(via = "as_str", generate_unknown, display)]
803pub enum ReplacementPolicy {
804    /// **Default** — Failed member is reaped + replaced immediately
805    /// (pool stays at `desired` count). Most production-like.
806    #[default]
807    ReplaceImmediate,
808    /// Failed member stays for inspection; pool runs short until the
809    /// operator manually reaps it. Useful for debugging.
810    HoldFailed,
811    /// Failed member triggers pool-wide pause: `desired` is
812    /// effectively 0 until the operator manually resumes via a
813    /// pool-status patch. Used for "halt on any failure" workflows.
814    PausePool,
815}
816
817impl ReplacementPolicy {
818    /// The closed set of replacement policies — single source of truth
819    /// that drives the `as_str` / Display / `FromStr` triad and the
820    /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
821    /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
822    /// predicate arm per projection — exhaustively checked by the
823    /// compiler (the `[Self; 3]` array literal forces the arity) and by
824    /// the predicate-pair injectivity test below (a new variant must
825    /// land in its own (replaces_failed, pauses_on_failure) bucket or
826    /// the author has to extend the consumer dispatch in
827    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
828    pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
829
830    /// Canonical PascalCase wire-format projection — matches the serde
831    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
832    /// enumeration the pool reconciler stamps on the
833    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
834    /// `replacement_policy_as_str_matches_serde` so a variant rename
835    /// can't drift between the typed surface, the CRD enum, the YAML
836    /// wire format AND the operator-facing diagnostic (the
837    /// `desired.rs` Pause reason composes `policy={policy}` via
838    /// Display, not a hard-coded `"PausePool"` literal that would
839    /// silently rot).
840    pub const fn as_str(self) -> &'static str {
841        match self {
842            Self::ReplaceImmediate => "ReplaceImmediate",
843            Self::HoldFailed => "HoldFailed",
844            Self::PausePool => "PausePool",
845        }
846    }
847
848    /// Should the pool auto-spawn a replacement for a Failed member?
849    /// Closed-set match (not `matches!`) so a future variant triggers
850    /// the compiler's exhaustiveness check at this site rather than
851    /// silently defaulting to `false`. Paired with
852    /// `pauses_on_failure` they form the two-axis projection
853    /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
854    /// pattern-match against — `replaces_failed` true ⇒ emit
855    /// `ReapFailed` per failure; `pauses_on_failure` true with any
856    /// failure ⇒ emit `Pause` and short-circuit. The pair is
857    /// `(true, false) | (false, false) | (false, true)` — pinned
858    /// injective by `replacement_policy_predicate_pair_is_injective`.
859    pub const fn replaces_failed(self) -> bool {
860        match self {
861            Self::ReplaceImmediate => true,
862            Self::HoldFailed | Self::PausePool => false,
863        }
864    }
865
866    /// Should reaching Failed on any member pause the whole pool?
867    /// See `replaces_failed` for the closed-match rationale + the
868    /// predicate-pair contract.
869    pub const fn pauses_on_failure(self) -> bool {
870        match self {
871            Self::PausePool => true,
872            Self::ReplaceImmediate | Self::HoldFailed => false,
873        }
874    }
875}
876
877// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
878// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
879// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
880// declaration above. `label` delegates to the inherent
881// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
882// PascalCase wire-format projection stays load-bearing (matches the
883// serde `rename_all = "PascalCase"` output AND the
884// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
885// emission verbatim) while generic `T: ClosedSet` consumers reach the
886// STABLE workspace-wide name (`label`); Display delegates to the same
887// inherent projection via `#[closed_set(display)]` so the
888// `Pause` reason emitter's `policy={policy}` composition stays
889// pinned on the closed-set algebra rather than on a hand-rolled
890// `fmt::Display` block per implementor.
891
892// `pub struct UnknownReplacementPolicy(pub String)` is generated by
893// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
894// on the enum declaration above. The auto-derived label
895// `"replacement policy"` matches the prior hand-rolled
896// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
897// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
898// [`crate::export::UnknownReportFormat`],
899// [`crate::export::UnknownChannelKind`],
900// [`crate::export::UnknownExportTrigger`],
901// [`crate::lifetime::UnknownTeardownPolicy`],
902// [`crate::boundary::UnknownConditionKind`], and
903// [`crate::phase::UnknownPhase`].
904
905fn default_free_ttl() -> String {
906    "24h".to_string()
907}
908fn default_max_allocation_ttl() -> String {
909    "4h".to_string()
910}
911
912/// `EphemeralPool.status` — observed pool population state.
913#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
914#[serde(rename_all = "camelCase")]
915pub struct PoolStatus {
916    /// Pool lifecycle phase.
917    #[serde(default)]
918    pub phase: PoolPhase,
919
920    /// When the pool entered the current phase.
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub phase_since: Option<DateTime<Utc>>,
923
924    /// Number of members currently in `Free` state (ready for allocation).
925    #[serde(default)]
926    pub ready_count: u32,
927
928    /// Number of members currently `Allocated`.
929    #[serde(default)]
930    pub allocated_count: u32,
931
932    /// Number of members currently `Spawning` (not yet Attested).
933    #[serde(default)]
934    pub spawning_count: u32,
935
936    /// Number of members currently `Returning` (reset or replace
937    /// in progress).
938    #[serde(default)]
939    pub returning_count: u32,
940
941    /// Member ledger — one entry per pool slot.
942    #[serde(default)]
943    pub members: Vec<PoolMember>,
944
945    /// Operator-visible message (e.g., "scaled down to floor").
946    #[serde(default, skip_serializing_if = "Option::is_none")]
947    pub message: Option<String>,
948
949    /// Standard Kubernetes Conditions.
950    #[serde(default)]
951    pub conditions: Vec<PoolCondition>,
952}
953
954/// One pool slot's state.
955#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
956#[serde(rename_all = "camelCase")]
957pub struct PoolMember {
958    /// `metadata.name` of the backing Process.
959    pub process_name: String,
960    /// Pool member's current slot state.
961    pub state: MemberState,
962    /// When the member entered the current state.
963    pub entered_state_at: DateTime<Utc>,
964    /// If allocated: the AllocationRef holding this slot.
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    pub allocation_ref: Option<AllocationRef>,
967}
968
969impl PoolStatus {
970    /// Substrate constructor for the observed [`PoolStatus`] seed:
971    /// composes the `(phase, phase_since, ready/allocated/spawning
972    /// /returning counts, members, message, conditions)` 9-slot record
973    /// every pool-reconciler status-patch site restated by hand pre-
974    /// lift. The four counters ride a SINGLE closed-set-driven fold
975    /// over the members list (one pass rather than four independent
976    /// filter-and-count passes); the `message` + `conditions` slots
977    /// stay at their invariant `None` / `vec![]` defaults every pre-
978    /// lift caller stamped verbatim, and `phase_since` is derived from
979    /// the caller-supplied `now` timestamp so the constructor stays
980    /// clock-injectable rather than implicitly reading wall time.
981    ///
982    /// Pre-lift the 11-line
983    /// ```rust,ignore
984    /// PoolStatus {
985    ///     phase,
986    ///     phase_since: Some(Utc::now()),
987    ///     ready_count: count_state(&members, MemberState::Free),
988    ///     allocated_count: count_state(&members, MemberState::Allocated),
989    ///     spawning_count: count_state(&members, MemberState::Spawning),
990    ///     returning_count: count_state(&members, MemberState::Returning),
991    ///     members: members.clone(),
992    ///     message: None,
993    ///     conditions: vec![],
994    /// }
995    /// ```
996    /// incantation was hand-authored at TWO sites past the ★★ PRIME-
997    /// DIRECTIVE ≥ 2 duplication threshold in
998    /// `tatara-pool-reconciler::controller_pool::reconcile_inner`,
999    /// both restating the same 4-slot count fanout + defaults:
1000    /// * The `desired > 0` path — status patch after the
1001    ///   convergence-action loop when the operator drives the pool
1002    ///   through the R11 desired-count invariant.
1003    /// * The legacy allocation-driven path (`desired == 0`) — status
1004    ///   patch after the [`crate::pool::PoolDecision`] apply loop.
1005    ///
1006    /// Both sites walked the SAME 4-slot count fanout on the SAME
1007    /// four `MemberState` variants (Free/Allocated/Spawning/Returning)
1008    /// and stamped the SAME defaults (`message: None`, `conditions:
1009    /// vec![]`), even though the four counters walked the members list
1010    /// four independent times pre-lift when a single pass suffices.
1011    /// Post-lift both callers write
1012    /// `PoolStatus::observed(phase, members, Utc::now())` and share
1013    /// ONE substrate owner; a future counter slot (e.g., a
1014    /// `warming_count` for a `MemberState::Warming` variant between
1015    /// Spawning and Free) plugs into the fold at ONE match arm and
1016    /// both status-patch sites inherit the new slot mechanically.
1017    ///
1018    /// The `Failed` variant is deliberately absent from the fold — no
1019    /// `PoolStatus` slot counts failed members (they surface via
1020    /// `pool_phase_from_members`'s `PoolPhase::Degraded` transition
1021    /// instead), and the closed-set match on
1022    /// [`MemberState`] pins that a future variant which SHOULD count
1023    /// toward one of the four buckets triggers the compiler's
1024    /// exhaustiveness check at this fold rather than silently sinking
1025    /// into `Failed`'s no-op arm.
1026    ///
1027    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1028    /// the 11-line status-seed incantation recurred at two hand-
1029    /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
1030    /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
1031    /// invariant 5 (composition preserves proofs — the pins bind the
1032    /// 4-slot count fanout + the closed-set exhaustiveness on
1033    /// `MemberState` + the invariant defaults, so a regression that
1034    /// dropped a counter slot or swapped a variant surfaces at
1035    /// `tests::pool_status_observed_*` rather than as silent operator-
1036    /// facing skew between the two status-patch sites on the SAME
1037    /// pool).
1038    #[must_use]
1039    pub fn observed(phase: PoolPhase, members: Vec<PoolMember>, now: DateTime<Utc>) -> Self {
1040        let (ready_count, allocated_count, spawning_count, returning_count) =
1041            PoolMember::state_count_fanout(&members);
1042        Self {
1043            phase,
1044            phase_since: Some(now),
1045            ready_count,
1046            allocated_count,
1047            spawning_count,
1048            returning_count,
1049            members,
1050            message: None,
1051            conditions: vec![],
1052        }
1053    }
1054}
1055
1056impl PoolMember {
1057    /// Substrate primitive: single-pass closed-set fold over a
1058    /// `[PoolMember]` slice producing the `(ready, allocated,
1059    /// spawning, returning)` 4-tuple every `PoolStatus` seed stamps at
1060    /// its four counter slots. The `Failed` arm is a no-op (no
1061    /// `PoolStatus` counter tracks failed members — they surface via
1062    /// [`PoolPhase::Degraded`] instead), pinned by the closed-set
1063    /// match so a future variant that SHOULD count toward one of the
1064    /// four buckets triggers the compiler's exhaustiveness check here
1065    /// rather than silently falling through.
1066    ///
1067    /// Consumed by [`PoolStatus::observed`]. A caller that needs a
1068    /// single per-variant count outside the status-seed fanout should
1069    /// keep spelling `members.iter().filter(...).count()` rather than
1070    /// walking this 4-tuple — the fanout is shaped for the
1071    /// `PoolStatus` fill, not for arbitrary per-variant queries.
1072    #[must_use]
1073    pub fn state_count_fanout(members: &[Self]) -> (u32, u32, u32, u32) {
1074        let mut ready = 0u32;
1075        let mut allocated = 0u32;
1076        let mut spawning = 0u32;
1077        let mut returning = 0u32;
1078        for m in members {
1079            match m.state {
1080                MemberState::Free => ready += 1,
1081                MemberState::Allocated => allocated += 1,
1082                MemberState::Spawning => spawning += 1,
1083                MemberState::Returning => returning += 1,
1084                MemberState::Failed => {}
1085            }
1086        }
1087        (ready, allocated, spawning, returning)
1088    }
1089
1090    /// Substrate primitive: single-pass closed-set collection of the
1091    /// `process_name` axis over a `[PoolMember]` slice into an owned
1092    /// `HashSet<String>` — the O(1)-lookup shape every spawn-arm on
1093    /// the workspace builds pre-collision-check against a candidate
1094    /// [`crate::pool::PoolMember::process_name`] produced by
1095    /// [`tatara-pool-reconciler::naming::member_process_name`].
1096    ///
1097    /// Pre-lift the 2-line
1098    /// `members.iter().map(|m| m.process_name.clone()).collect()`
1099    /// chain was hand-authored at TWO sites past the ★★ PRIME-
1100    /// DIRECTIVE ≥ 2 duplication threshold in
1101    /// `tatara-pool-reconciler::controller_pool`, both restating the
1102    /// SAME `process_name` projection through the SAME
1103    /// `iter → map → collect` shape and both feeding a `.contains
1104    /// (&candidate)` probe:
1105    /// * `reconcile_inner`'s legacy allocation-driven
1106    ///   `PoolDecision::Spawn` arm (`desired == 0` path) —
1107    ///   collision-set for
1108    ///   `member_process_name(&pool_name, &pool_uid, slot)` per spawn
1109    ///   slot.
1110    /// * `apply_convergence_actions` — collision-set for the SAME
1111    ///   composer inside the R11 desired-count
1112    ///   `ConvergenceAction::CreateMember` loop.
1113    ///
1114    /// Post-lift both consumers share ONE substrate owner; the
1115    /// composed `HashSet<String>` still feeds the same
1116    /// `HashSet::<String>::contains(&candidate)` probe at each
1117    /// callsite unchanged. A future normalization step on the
1118    /// occupied-name axis (case-fold before insertion, a per-cluster
1119    /// prefix strip, deduplication against a sibling stale-name
1120    /// registry, exclusion of `Returning`/`Failed` members that no
1121    /// longer own their slot) lands at ONE substrate method rather
1122    /// than being restated at each callsite.
1123    ///
1124    /// Sibling to [`Self::state_count_fanout`] on the `(collection
1125    /// shape × slice-owned fold)` axis: both primitives fold a
1126    /// `[PoolMember]` slice into one caller-shaped aggregate in a
1127    /// single pass, both are `#[must_use]`, both take the slice by
1128    /// reference so no caller has to reshape its `Vec<PoolMember>` or
1129    /// `Vec<PoolMember>` slice upstream.
1130    ///
1131    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1132    /// the `HashSet<String>` collision-set shape recurred at TWO
1133    /// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
1134    /// duplication trigger, and is lifted to ONE owner here).
1135    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
1136    /// the pins bind the axis (`process_name`), the aggregate shape
1137    /// (`HashSet<String>`), the empty-slice corner, and the
1138    /// duplicate-name deduplication semantics `HashSet` provides
1139    /// implicitly, so a regression at any of those surfaces at
1140    /// `tests::process_names_set_*` rather than as silent occupied-
1141    /// slot skew at either spawn arm).
1142    #[must_use]
1143    pub fn process_names_set(members: &[Self]) -> std::collections::HashSet<String> {
1144        members.iter().map(|m| m.process_name.clone()).collect()
1145    }
1146}
1147
1148/// Light reference to an `EphemeralAllocation`.
1149#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
1150#[serde(rename_all = "camelCase")]
1151pub struct AllocationRef {
1152    pub name: String,
1153    pub namespace: String,
1154}
1155
1156impl AllocationRef {
1157    /// Substrate constructor for [`AllocationRef`]: composes the
1158    /// `(name, namespace)` pair through ONE `impl Into<String>`-gated
1159    /// entry point — the ONE-liner collapse of the paired
1160    /// `AllocationRef { name: n.into(), namespace: ns.into() }`
1161    /// struct-literal incantation every downstream consumer restated
1162    /// by hand pre-lift.
1163    ///
1164    /// Pre-lift the `AllocationRef { name, namespace }` struct-literal
1165    /// was hand-authored at FOUR production sites past the ★★ PRIME-
1166    /// DIRECTIVE ≥ 2 duplication threshold across the workspace, all
1167    /// composing an owned `(name: String, namespace: String)` pair
1168    /// under one of two roles:
1169    /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
1170    ///   Bind path — the `assignedProcess` status slot's ref, pairing
1171    ///   the just-bound member Process name with the allocation's
1172    ///   containing namespace.
1173    /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
1174    ///   Release path — the same `assignedProcess` slot shape, stamped
1175    ///   at the release-side status patch alongside the (unchanged)
1176    ///   `boundPool` ref.
1177    /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`
1178    ///   pool-matched handle — the `matched_pool` slot's ref, pairing
1179    ///   [`EphemeralPool::owned_name_or_empty`] with the pool's
1180    ///   containing namespace.
1181    /// * `tatara-github-watcher::allocation_factory::allocation_from_pr`
1182    ///   — the `pool_ref` slot on the `AllocationSpec` emitted from a
1183    ///   PullRequestEvent, pairing the operator-configured pool name
1184    ///   with the watcher's target namespace.
1185    ///
1186    /// All FOUR sites walked the SAME two-field struct-literal shape
1187    /// — an owned name half, an owned namespace half — differing only
1188    /// in provenance. Post-lift each callsite reads
1189    /// `AllocationRef::new(name, ns)` and the produced value feeds the
1190    /// same downstream slot (`assignedProcess` / `bound_pool` /
1191    /// `matched_pool` / `spec.pool_ref`) unchanged. The `impl Into<String>`
1192    /// signature accepts every provenance the pre-lift sites carried —
1193    /// owned `String` (the reconciler's owned-form projections), `&str`
1194    /// (the factory's `n.to_string()` / `namespace.to_string()`
1195    /// borrow-to-owned promotions), `Cow<str>`, and every other
1196    /// `Into<String>` implementor — so no callsite has to change its
1197    /// upstream provenance to route through the primitive.
1198    ///
1199    /// Return-form axis: owned [`AllocationRef`] — the wire-format
1200    /// shape [`crate::pool::AllocationRef`]'s serde `rename_all =
1201    /// "camelCase"` produces on both spec (`poolRef`) and status
1202    /// (`boundPool` / `assignedProcess`) slots. The primitive owns
1203    /// the axis-order `(name, namespace)` — the same order the four
1204    /// consumers spelled — so a slot swap surfaces at the
1205    /// `allocation_ref_new_positional_axis_order` pin below rather
1206    /// than as silent `<namespace>/<name>` inversion downstream.
1207    ///
1208    /// Peer to the sibling substrate primitives already opened on the
1209    /// pool-side (name, namespace) axis pair:
1210    /// [`EphemeralPool::name_or_empty`] (borrow-form name),
1211    /// [`EphemeralPool::owned_name_or_empty`] (owned-form name); this
1212    /// constructor is the composer that folds the owned-form projections
1213    /// into the wire-format ref shape.
1214    ///
1215    /// A future refactor of [`AllocationRef`]'s field set (a
1216    /// `resource_kind: String` field for cross-CRD refs, an
1217    /// `api_version: String` field for FQN references, a
1218    /// canonicalization pass over the namespace half, a non-empty-name
1219    /// gate) lands at ONE substrate constructor site here and every
1220    /// downstream consumer inherits the upgrade mechanically — no per-
1221    /// callsite hand-edit at the FOUR reconciler + factory sites.
1222    ///
1223    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1224    /// the `AllocationRef { name, namespace }` struct-literal shape
1225    /// recurred at four hand-authored sites past the ★★ PRIME-
1226    /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
1227    /// here). THEORY.md §II.1 invariant 5 (composition preserves
1228    /// proofs — the pins bind the positional axis-order + the
1229    /// `Into<String>` provenance closure + byte-identical parity with
1230    /// the pre-lift struct-literal + `PartialEq` coherence with the
1231    /// hand-authored form, so a regression that reshaped any surface
1232    /// at `tests::allocation_ref_new_*` rather than as silent
1233    /// operator-facing skew between the assignedProcess / bound_pool
1234    /// / matched_pool / spec.pool_ref slots on the SAME allocation).
1235    #[must_use]
1236    pub fn new(name: impl Into<String>, namespace: impl Into<String>) -> Self {
1237        Self {
1238            name: name.into(),
1239            namespace: namespace.into(),
1240        }
1241    }
1242}
1243
1244/// Per-slot state in the pool's free list.
1245///
1246/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
1247/// (the on-failure policy that the pool reconciler dispatches against
1248/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
1249/// release-time disposition that transitions an [`Self::Allocated`]
1250/// member into [`Self::Returning`] before it either re-enters
1251/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
1252#[derive(
1253    Clone,
1254    Copy,
1255    Debug,
1256    PartialEq,
1257    Eq,
1258    Hash,
1259    Serialize,
1260    Deserialize,
1261    JsonSchema,
1262    tatara_closed_set::DeriveClosedSet,
1263)]
1264#[serde(rename_all = "PascalCase")]
1265#[closed_set(via = "as_str", generate_unknown, display)]
1266pub enum MemberState {
1267    /// Pool reconciler is creating/converging the backing Process.
1268    Spawning,
1269    /// Process is `Attested`; ready for allocation.
1270    Free,
1271    /// Held by an `EphemeralAllocation`.
1272    Allocated,
1273    /// Return policy is being applied (Reset → reset Job; Replace →
1274    /// Process is being torn down and recreated).
1275    Returning,
1276    /// Permanent failure — the member needs operator attention.
1277    Failed,
1278}
1279
1280impl MemberState {
1281    /// The closed set of member states — single source of truth that
1282    /// drives the `as_str` / Display / `FromStr` triad AND the
1283    /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
1284    /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
1285    /// arm per predicate — exhaustively checked by the compiler (the
1286    /// `[Self; 5]` array literal forces the arity) and by the
1287    /// per-variant truth-table contract test (a new variant must
1288    /// declare its own `(is_failed, counts_toward_supply)` projection
1289    /// or the consumer dispatch in
1290    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1291    /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1292    /// will silently bucket it into the wrong lifecycle column).
1293    pub const ALL: [Self; 5] = [
1294        Self::Spawning,
1295        Self::Free,
1296        Self::Allocated,
1297        Self::Returning,
1298        Self::Failed,
1299    ];
1300
1301    /// Canonical PascalCase wire-format projection — matches the serde
1302    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
1303    /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
1304    /// `status.members[].state`. Pinned by
1305    /// `member_state_as_str_matches_serde` so a variant rename can't
1306    /// drift between the typed surface, the CRD enum, the YAML wire
1307    /// format AND any future operator-facing diagnostic that composes
1308    /// `state={state}` via Display rather than a hard-coded literal
1309    /// that would silently rot.
1310    pub const fn as_str(self) -> &'static str {
1311        match self {
1312            Self::Spawning => "Spawning",
1313            Self::Free => "Free",
1314            Self::Allocated => "Allocated",
1315            Self::Returning => "Returning",
1316            Self::Failed => "Failed",
1317        }
1318    }
1319
1320    /// Is this member in a permanent-failure state — needs operator
1321    /// attention? Closed-set match (not `matches!`) so a future variant
1322    /// triggers the compiler's exhaustiveness check at this site rather
1323    /// than silently defaulting to `false`. Consumed by
1324    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
1325    /// gate the highest-priority `ReplaceMembers` decision branch — a
1326    /// future variant that should also trigger replacement (e.g.
1327    /// `MemberState::Quarantined`) flips this predicate at one site
1328    /// and inherits the priority-1 dispatch without touching the
1329    /// consumer match arm.
1330    pub const fn is_failed(self) -> bool {
1331        match self {
1332            Self::Failed => true,
1333            Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
1334        }
1335    }
1336
1337    /// Does this member contribute to the pool's *available supply*
1338    /// (current ready slots + slots coming online)? Closed-set match so
1339    /// a future variant triggers the compiler's exhaustiveness check.
1340    /// Consumed by
1341    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1342    /// — the `(free + spawning)` supply calc collapses into one
1343    /// predicate-driven filter, so a future "warming-up" state
1344    /// (`MemberState::Warming` between Spawning and Free) plugs into
1345    /// the supply count at one site rather than three. Disjoint with
1346    /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
1347    /// (a Failed member can never count toward supply; the pool
1348    /// reconciler would otherwise double-count failures as available
1349    /// capacity).
1350    pub const fn counts_toward_supply(self) -> bool {
1351        match self {
1352            Self::Free | Self::Spawning => true,
1353            Self::Allocated | Self::Returning | Self::Failed => false,
1354        }
1355    }
1356}
1357
1358// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
1359// MemberState` + `impl fmt::Display for MemberState` are generated by
1360// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1361// above. `label` delegates to the inherent `MemberState::as_str` via
1362// `#[closed_set(via = "as_str")]` so the
1363// `pool_phase_from_members` supply calc can keep keying on
1364// `counts_toward_supply` against the typed variant while a generic
1365// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
1366// (`label`) without knowing this enum lives in `tatara-process::pool`;
1367// Display delegates to the same inherent projection via
1368// `#[closed_set(display)]` so the diagnostic emitter's
1369// `state={state}` composition stays pinned on the closed-set algebra.
1370
1371// `pub struct UnknownMemberState(pub String)` is generated by
1372// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1373// on the enum declaration above. The auto-derived label `"member state"`
1374// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
1375// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1376// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
1377// [`crate::lifetime::UnknownTeardownPolicy`],
1378// [`crate::boundary::UnknownConditionKind`], and
1379// [`crate::phase::UnknownPhase`].
1380
1381/// Pool lifecycle phase (observed across the whole pool population).
1382///
1383/// Sibling closed-set on the same `EphemeralPool` axis as
1384/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
1385/// over via [`MemberState::counts_toward_supply`]),
1386/// [`ReplacementPolicy::ALL`] (on-failure policy) and
1387/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
1388/// `MemberState`, this closes the pool reconciler's
1389/// `(slot-state, pool-phase)` two-tier observation algebra on the
1390/// same closed-set discipline as the rest of `tatara-process`.
1391#[derive(
1392    Clone,
1393    Copy,
1394    Debug,
1395    PartialEq,
1396    Eq,
1397    Hash,
1398    Serialize,
1399    Deserialize,
1400    JsonSchema,
1401    tatara_closed_set::DeriveClosedSet,
1402)]
1403#[serde(rename_all = "PascalCase")]
1404#[closed_set(via = "as_str", generate_unknown, display)]
1405pub enum PoolPhase {
1406    /// Just admitted; no members yet.
1407    Initializing,
1408    /// `ready_count == desired_size`.
1409    Steady,
1410    /// `ready_count + spawning_count < desired_size` and reconciler
1411    /// is creating new members.
1412    ScalingUp,
1413    /// `ready_count > desired_size` and reconciler is reaping excess.
1414    ScalingDown,
1415    /// `min_size` constraint violated.
1416    Degraded,
1417    /// Pool is being deleted; reconciler is reaping all members.
1418    Draining,
1419}
1420
1421impl Default for PoolPhase {
1422    fn default() -> Self {
1423        Self::Initializing
1424    }
1425}
1426
1427impl PoolPhase {
1428    /// The closed set of pool phases — single source of truth that
1429    /// drives the `as_str` / Display / `FromStr` triad AND the
1430    /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
1431    /// variant lands at one `ALL` entry + one `as_str` arm + one arm
1432    /// per predicate — exhaustively checked by the compiler (the
1433    /// `[Self; 6]` array literal forces the arity) AND by the
1434    /// per-variant truth-table contract test (a new variant must
1435    /// declare its own `(is_steady, is_terminal)` projection or any
1436    /// future status-aggregator surface — `feira pool list
1437    /// --healthy`, the operator-facing condition aggregator, the
1438    /// desired-loop heartbeat short-circuit — will silently bucket
1439    /// it into the wrong lifecycle column).
1440    pub const ALL: [Self; 6] = [
1441        Self::Initializing,
1442        Self::Steady,
1443        Self::ScalingUp,
1444        Self::ScalingDown,
1445        Self::Degraded,
1446        Self::Draining,
1447    ];
1448
1449    /// Canonical PascalCase wire-format projection — matches the
1450    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1451    /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
1452    /// stamps on `status.phase`. Pinned by
1453    /// `pool_phase_as_str_matches_serde` so a variant rename can't
1454    /// drift between the typed surface, the CRD enum, the YAML wire
1455    /// format AND any future operator-facing diagnostic that
1456    /// composes `phase={phase}` via Display rather than a hard-coded
1457    /// literal that would silently rot. Display + FromStr triad
1458    /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
1459    /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
1460    /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
1461    pub const fn as_str(self) -> &'static str {
1462        match self {
1463            Self::Initializing => "Initializing",
1464            Self::Steady => "Steady",
1465            Self::ScalingUp => "ScalingUp",
1466            Self::ScalingDown => "ScalingDown",
1467            Self::Degraded => "Degraded",
1468            Self::Draining => "Draining",
1469        }
1470    }
1471
1472    /// Is the pool fully converged — supply matches desired, no
1473    /// reconciler-driven population change pending? Closed-set match
1474    /// (not `matches!`) so a future variant triggers the compiler's
1475    /// exhaustiveness check at this site rather than silently
1476    /// defaulting to `false`. Paired with `is_terminal` they form
1477    /// the two-axis projection that future status aggregators
1478    /// (operator-facing fleet health, `feira pool list --healthy`,
1479    /// the SSE filter "show non-steady pools") dispatch against —
1480    /// `is_steady && !is_terminal` ⇒ converged (goal state);
1481    /// `!is_steady && is_terminal` ⇒ being deleted (no future
1482    /// spawn); `!is_steady && !is_terminal` ⇒ transient
1483    /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
1484    /// is in motion toward desired). The impossible bucket
1485    /// `(true, true)` — a draining pool that's somehow also steady
1486    /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
1487    pub const fn is_steady(self) -> bool {
1488        match self {
1489            Self::Steady => true,
1490            Self::Initializing
1491            | Self::ScalingUp
1492            | Self::ScalingDown
1493            | Self::Degraded
1494            | Self::Draining => false,
1495        }
1496    }
1497
1498    /// Is the pool in its absorbing exit state — deletion-stamped,
1499    /// reconciler is reaping every member, no spawn will ever
1500    /// happen again? Closed-set match so a future variant triggers
1501    /// the compiler's exhaustiveness check. See `is_steady` for the
1502    /// predicate-pair contract + bucket definitions.
1503    pub const fn is_terminal(self) -> bool {
1504        match self {
1505            Self::Draining => true,
1506            Self::Initializing
1507            | Self::Steady
1508            | Self::ScalingUp
1509            | Self::ScalingDown
1510            | Self::Degraded => false,
1511        }
1512    }
1513}
1514
1515// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
1516// + `impl fmt::Display for PoolPhase` are generated by
1517// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
1518// `label` delegates to the inherent `PoolPhase::as_str` via
1519// `#[closed_set(via = "as_str")]` so the operator-facing
1520// `phase={phase}` Display composition keeps reading the same canonical
1521// PascalCase projection while a generic `T: ClosedSet` consumer (a
1522// status-aggregator filter, the `feira pool list --healthy` predicate, a
1523// future SSE event router) can walk every variant without knowing the
1524// closed set lives in `tatara-process::pool`; Display delegates to the
1525// same inherent projection via `#[closed_set(display)]` so the
1526// `phase={phase}` composition stays pinned on the closed-set algebra
1527// rather than a hand-rolled `fmt::Display` block.
1528
1529// `pub struct UnknownPoolPhase(pub String)` is generated by
1530// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1531// on the enum declaration above. The auto-derived label `"pool phase"`
1532// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
1533// verbatim. Symmetric to [`UnknownMemberState`],
1534// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
1535// [`crate::lifetime::UnknownTeardownPolicy`],
1536// [`crate::boundary::UnknownConditionKind`], and
1537// [`crate::phase::UnknownPhase`].
1538
1539/// Standard K8s Condition shape (kept local so tatara-process doesn't
1540/// depend on k8s_openapi types in its public schema).
1541#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
1542#[serde(rename_all = "camelCase")]
1543pub struct PoolCondition {
1544    pub type_: String,
1545    pub status: String,
1546    pub reason: String,
1547    pub message: String,
1548    pub last_transition_time: DateTime<Utc>,
1549}
1550
1551/// What the pool does when an allocation releases a member.
1552///
1553/// Sibling closed-set on the `EphemeralPool` axis:
1554/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
1555/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
1556/// (the *release*-time counterpart for non-pooled ephemeral envs),
1557/// [`crate::boundary::ConditionKind::ALL`],
1558/// [`crate::lifetime::LifetimeKind::ALL`],
1559/// [`crate::intent::IntentKind::ALL`],
1560/// [`crate::phase::ProcessPhase::ALL`],
1561/// [`crate::signal::ProcessSignal::ALL`].
1562#[derive(
1563    Clone,
1564    Copy,
1565    Debug,
1566    Hash,
1567    PartialEq,
1568    Eq,
1569    Serialize,
1570    Deserialize,
1571    JsonSchema,
1572    Default,
1573    tatara_closed_set::DeriveClosedSet,
1574)]
1575#[serde(rename_all = "PascalCase")]
1576#[closed_set(via = "as_str", generate_unknown, display)]
1577pub enum ReturnPolicy {
1578    /// Tear down the Process + create a fresh one. Safe but slow
1579    /// (1-2 min spin-up before the slot is Free again).
1580    #[default]
1581    Replace,
1582    /// Keep the Process running; run a typed `:reset` Job that wipes
1583    /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
1584    /// the reset Job being correct for the workload. API-authoritative
1585    /// systems are natural fits because the control API owns all state.
1586    Reset,
1587    /// Keep the Process indefinitely after release (debugging aid;
1588    /// operator must `feira pool reap NAME` to clean up). Useful for
1589    /// post-mortem of a flaky test.
1590    Keep,
1591}
1592
1593impl ReturnPolicy {
1594    /// The closed set of return policies — single source of truth that
1595    /// drives the `as_str` / Display / `FromStr` triad and the
1596    /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
1597    /// fourth variant lands at one `ALL` entry + one `as_str` arm +
1598    /// one arm per predicate — exhaustively checked by the compiler
1599    /// (the `[Self; 3]` array literal forces the arity) and by the
1600    /// predicate-pair injectivity test (a new variant must land in
1601    /// its own (keeps_process, runs_reset_job) bucket or the author
1602    /// has to extend the consumer dispatch in
1603    /// `tatara-pool-reconciler::return_policy::plan_return`).
1604    pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
1605
1606    /// Canonical PascalCase wire-format projection — matches the
1607    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1608    /// `enum:` enumeration the pool reconciler stamps on the
1609    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
1610    /// `return_policy_as_str_matches_serde` so a variant rename can't
1611    /// drift between the typed surface, the CRD enum, the YAML wire
1612    /// format AND any future operator-facing diagnostic that composes
1613    /// `policy={policy}` via Display rather than a hard-coded literal.
1614    pub const fn as_str(self) -> &'static str {
1615        match self {
1616            Self::Replace => "Replace",
1617            Self::Reset => "Reset",
1618            Self::Keep => "Keep",
1619        }
1620    }
1621
1622    /// Does the pool keep the backing Process alive across release?
1623    /// Closed-set match (not `matches!`) so a future variant triggers
1624    /// the compiler's exhaustiveness check at this site rather than
1625    /// silently defaulting to `false`. Paired with `runs_reset_job`
1626    /// they form the two-axis projection that the consumer in
1627    /// `tatara-pool-reconciler::return_policy::plan_return` matches
1628    /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
1629    /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
1630    /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
1631    /// pair is `(false, false) | (true, true) | (true, false)` —
1632    /// pinned injective by
1633    /// `return_policy_predicate_pair_is_injective`.
1634    pub const fn keeps_process(self) -> bool {
1635        match self {
1636            Self::Replace => false,
1637            Self::Reset | Self::Keep => true,
1638        }
1639    }
1640
1641    /// Does the policy run a typed `:reset` Job to wipe state in
1642    /// place? See `keeps_process` for the closed-match rationale +
1643    /// the predicate-pair contract.
1644    pub const fn runs_reset_job(self) -> bool {
1645        match self {
1646            Self::Reset => true,
1647            Self::Replace | Self::Keep => false,
1648        }
1649    }
1650}
1651
1652// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
1653// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
1654// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1655// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
1656// `#[closed_set(via = "as_str")]` so the
1657// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
1658// reading the canonical PascalCase projection that matches the CRD
1659// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
1660// plugs in without knowing the enum lives in `tatara-process::pool`;
1661// Display delegates to the same inherent projection via
1662// `#[closed_set(display)]` so the `policy={policy}` diagnostic
1663// composition stays pinned on the closed-set algebra.
1664
1665// `pub struct UnknownReturnPolicy(pub String)` is generated by
1666// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1667// on the enum declaration above. The auto-derived label `"return policy"`
1668// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
1669// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1670// [`UnknownMemberState`], [`UnknownPoolPhase`],
1671// [`crate::lifetime::UnknownTeardownPolicy`],
1672// [`crate::boundary::UnknownConditionKind`], and
1673// [`crate::phase::UnknownPhase`].
1674
1675/// Routing selector — matches an `EphemeralAllocation`'s requestor
1676/// against pool-eligibility predicates.
1677#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1678#[serde(rename_all = "camelCase")]
1679pub struct PoolSelector {
1680    /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
1681    /// Empty = match every repo.
1682    #[serde(default)]
1683    pub repos: Vec<String>,
1684
1685    /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
1686    /// Empty = match every branch.
1687    #[serde(default)]
1688    pub branches: Vec<String>,
1689
1690    /// PR labels (all-must-match, AND semantics). Empty = no label
1691    /// requirement.
1692    #[serde(default)]
1693    pub pr_labels: Vec<String>,
1694
1695    /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
1696    /// "manual", "ci-run"). Empty = any kind.
1697    #[serde(default)]
1698    pub kinds: Vec<String>,
1699}
1700
1701impl PoolSelector {
1702    /// Does this selector match the given allocation routing key?
1703    /// Pure: no side effects.
1704    pub fn matches(&self, key: &MatchKey<'_>) -> bool {
1705        glob_any(&self.repos, key.repo)
1706            && glob_any(&self.branches, key.branch)
1707            && labels_subset(&self.pr_labels, key.pr_labels)
1708            && kind_any(&self.kinds, key.kind)
1709    }
1710
1711    /// Specificity score — higher = more specific. Used by the
1712    /// reconciler to break ties between selectors that all match.
1713    pub fn specificity(&self) -> u32 {
1714        let mut score = 0;
1715        if !self.repos.is_empty() {
1716            score += 8;
1717        }
1718        if !self.branches.is_empty() {
1719            score += 4;
1720        }
1721        score += (self.pr_labels.len() as u32) * 2;
1722        if !self.kinds.is_empty() {
1723            score += 1;
1724        }
1725        score
1726    }
1727}
1728
1729/// Allocation routing key — what the reconciler matches against pool selectors.
1730#[derive(Clone, Copy, Debug)]
1731pub struct MatchKey<'a> {
1732    pub repo: &'a str,
1733    pub branch: &'a str,
1734    pub pr_labels: &'a [String],
1735    pub kind: &'a str,
1736}
1737
1738fn glob_any(patterns: &[String], value: &str) -> bool {
1739    if patterns.is_empty() {
1740        return true;
1741    }
1742    patterns.iter().any(|p| glob_match(p, value))
1743}
1744
1745fn kind_any(kinds: &[String], value: &str) -> bool {
1746    if kinds.is_empty() {
1747        return true;
1748    }
1749    kinds.iter().any(|k| k == value)
1750}
1751
1752fn labels_subset(required: &[String], present: &[String]) -> bool {
1753    required.iter().all(|r| present.iter().any(|p| p == r))
1754}
1755
1756/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
1757/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
1758/// matches anything.
1759fn glob_match(pattern: &str, value: &str) -> bool {
1760    if pattern.is_empty() {
1761        return true;
1762    }
1763    if let Some(prefix) = pattern.strip_suffix('*') {
1764        value.starts_with(prefix)
1765    } else {
1766        pattern == value
1767    }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use super::*;
1773    // The closed-set tests below call `T::from_str(bad)` via the
1774    // derive-generated `FromStr` impls — bring the trait into scope at
1775    // the test module so the lib body doesn't carry an otherwise-unused
1776    // `use std::str::FromStr;` at the file head.
1777    use std::str::FromStr;
1778
1779    #[test]
1780    fn glob_trailing_star_matches_prefix() {
1781        assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
1782        assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
1783        assert!(glob_match("release-*", "release-2026-05"));
1784        assert!(!glob_match("release-*", "main"));
1785        assert!(glob_match("main", "main"));
1786        assert!(!glob_match("main", "develop"));
1787    }
1788
1789    #[test]
1790    fn empty_selector_matches_anything() {
1791        let s = PoolSelector::default();
1792        assert!(s.matches(&MatchKey {
1793            repo: "any/repo",
1794            branch: "any-branch",
1795            pr_labels: &[],
1796            kind: "any",
1797        }));
1798    }
1799
1800    #[test]
1801    fn repo_glob_filters_match_key() {
1802        let s = PoolSelector {
1803            repos: vec!["pleme-io/demo-*".into()],
1804            ..Default::default()
1805        };
1806        assert!(s.matches(&MatchKey {
1807            repo: "pleme-io/demo-app",
1808            branch: "x",
1809            pr_labels: &[],
1810            kind: "y",
1811        }));
1812        assert!(!s.matches(&MatchKey {
1813            repo: "pleme-io/other-repo",
1814            branch: "x",
1815            pr_labels: &[],
1816            kind: "y",
1817        }));
1818    }
1819
1820    #[test]
1821    fn pr_labels_require_all() {
1822        let s = PoolSelector {
1823            pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
1824            ..Default::default()
1825        };
1826        // Both labels present → match.
1827        assert!(s.matches(&MatchKey {
1828            repo: "x",
1829            branch: "y",
1830            pr_labels: &[
1831                "needs-ephemeral".into(),
1832                "integration".into(),
1833                "extra".into()
1834            ],
1835            kind: "z",
1836        }));
1837        // One label missing → no match.
1838        assert!(!s.matches(&MatchKey {
1839            repo: "x",
1840            branch: "y",
1841            pr_labels: &["needs-ephemeral".into()],
1842            kind: "z",
1843        }));
1844    }
1845
1846    #[test]
1847    fn specificity_ranks_more_constrained_higher() {
1848        let general = PoolSelector::default();
1849        let specific = PoolSelector {
1850            repos: vec!["pleme-io/*".into()],
1851            branches: vec!["main".into()],
1852            pr_labels: vec!["needs-ephemeral".into()],
1853            kinds: vec!["github-pr".into()],
1854        };
1855        assert!(specific.specificity() > general.specificity());
1856    }
1857
1858    #[test]
1859    fn return_policy_defaults_to_replace() {
1860        assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
1861    }
1862
1863    #[test]
1864    fn pool_phase_defaults_to_initializing() {
1865        assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
1866    }
1867
1868    // ── closed-set algebra contracts for ReplacementPolicy
1869    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
1870
1871    /// Structural well-formedness of [`ReplacementPolicy`] as a
1872    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1873    /// testkit lift that pins all three structural invariants (`ALL`
1874    /// is non-empty, every variant round-trips through
1875    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1876    /// outside the closed set) at ONE call site. Replaces the hand-
1877    /// derived `replacement_policy_all_is_unique_and_complete` +
1878    /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
1879    /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
1880    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1881    /// exercises the same code path the pool reconciler hits when
1882    /// parsing a CRD `enum:`-validated value back to the typed policy.
1883    #[test]
1884    fn replacement_policy_is_well_formed_closed_set() {
1885        tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
1886    }
1887
1888    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1889    /// output verbatim for every variant. A future variant rename (or
1890    /// an `as_str` arm typo) lands here at one site, instead of
1891    /// drifting between the typed surface, the CRD enum, and the
1892    /// YAML wire format.
1893    #[test]
1894    fn replacement_policy_as_str_matches_serde() {
1895        crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
1896    }
1897
1898    /// The Display impl IS `as_str` — pinning this lets future callers
1899    /// reach for either projection without drift. The operator-facing
1900    /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1901    /// composes through Display rather than through a hard-coded
1902    /// variant string.
1903    #[test]
1904    fn replacement_policy_display_matches_as_str() {
1905        crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
1906    }
1907
1908    /// `FromStr` rejects strings that aren't in the canonical
1909    /// projection — lowercased / typo / cross-axis-leaked — and the
1910    /// error echoes the input verbatim so the operator-facing
1911    /// diagnostic carries the offending value, not a normalized form.
1912    /// The empty-input arm is pinned by
1913    /// [`replacement_policy_is_well_formed_closed_set`] via the
1914    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1915    /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1916    /// newtype, which the trait's `make_unknown` can't see.
1917    #[test]
1918    fn unknown_replacement_policy_errors() {
1919        for bad in [
1920            "replaceimmediate",
1921            "PAUSEPOOL",
1922            "Replace-Immediate",
1923            "hold_failed",
1924            "Pause",
1925            "Reset",
1926        ] {
1927            let err = ReplacementPolicy::from_str(bad).unwrap_err();
1928            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1929        }
1930    }
1931
1932    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1933    /// documented per-variant on-failure behavior.
1934    #[test]
1935    fn replacement_policy_predicate_truth_tables() {
1936        assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1937        assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1938
1939        assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1940        assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1941
1942        assert!(!ReplacementPolicy::PausePool.replaces_failed());
1943        assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1944    }
1945
1946    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1947    /// predicates simultaneously — the two on-failure actions
1948    /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1949    /// A future `ReplacementPolicy::PauseAndReap` that returned true
1950    /// from both would FAIL here, forcing the author to either pick
1951    /// one bucket or extend the consumer dispatch site in
1952    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1953    /// deliberately rather than silently double-firing both branches.
1954    #[test]
1955    fn replacement_policy_predicates_are_disjoint() {
1956        for policy in ReplacementPolicy::ALL {
1957            assert!(
1958                !(policy.replaces_failed() && policy.pauses_on_failure()),
1959                "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1960            );
1961        }
1962    }
1963
1964    /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1965    /// pauses_on_failure)` is injective across `ALL`. Each variant
1966    /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1967    /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1968    /// this with the disjointness contract above forces a future
1969    /// variant to land in a fresh `(replaces_failed,
1970    /// pauses_on_failure)` bucket — or the author extends the consumer
1971    /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1972    /// to recognize the new projection bucket.
1973    #[test]
1974    fn replacement_policy_predicate_pair_is_injective() {
1975        let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1976            .into_iter()
1977            .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1978            .collect();
1979        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1980        assert_eq!(
1981            projections.len(),
1982            unique.len(),
1983            "predicate pair projection is not injective: {projections:?}",
1984        );
1985    }
1986
1987    /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1988    /// returns the variant tagged `#[default]` in the enum, AND that
1989    /// variant reaps (the production-safe behavior). A future #[default]
1990    /// rename without flipping the predicates fails here.
1991    #[test]
1992    fn replacement_policy_default_replaces_failed() {
1993        let d = ReplacementPolicy::default();
1994        assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1995        assert!(d.replaces_failed());
1996        assert!(!d.pauses_on_failure());
1997    }
1998
1999    #[test]
2000    fn kinds_filter_to_known_set() {
2001        let s = PoolSelector {
2002            kinds: vec!["github-pr".into(), "manual".into()],
2003            ..Default::default()
2004        };
2005        assert!(s.matches(&MatchKey {
2006            repo: "x",
2007            branch: "y",
2008            pr_labels: &[],
2009            kind: "github-pr",
2010        }));
2011        assert!(!s.matches(&MatchKey {
2012            repo: "x",
2013            branch: "y",
2014            pr_labels: &[],
2015            kind: "scheduled",
2016        }));
2017    }
2018
2019    // ── closed-set algebra contracts for ReturnPolicy
2020    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
2021
2022    /// Structural well-formedness of [`ReturnPolicy`] as a
2023    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2024    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
2025    /// above.
2026    #[test]
2027    fn return_policy_is_well_formed_closed_set() {
2028        tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
2029    }
2030
2031    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2032    /// output verbatim for every variant. A future variant rename (or
2033    /// an `as_str` arm typo) lands here at one site, instead of
2034    /// drifting between the typed surface, the CRD enum, and the
2035    /// YAML wire format.
2036    #[test]
2037    fn return_policy_as_str_matches_serde() {
2038        crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
2039    }
2040
2041    /// The Display impl IS `as_str` — pinning this lets future callers
2042    /// reach for either projection without drift, mirroring the
2043    /// `ReplacementPolicy` discipline.
2044    #[test]
2045    fn return_policy_display_matches_as_str() {
2046        crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
2047    }
2048
2049    /// `FromStr` rejects strings that aren't in the canonical
2050    /// projection — lowercased / typo / cross-axis-leaked — and the
2051    /// error echoes the input verbatim so the operator-facing
2052    /// diagnostic carries the offending value, not a normalized form.
2053    /// The empty-input arm is pinned by
2054    /// [`return_policy_is_well_formed_closed_set`] via the
2055    /// `tatara_lisp::ClosedSet` testkit.
2056    #[test]
2057    fn unknown_return_policy_errors() {
2058        for bad in [
2059            "replace",
2060            "RESET",
2061            "Re-place",
2062            "keep_for_inspection",
2063            "DeleteAndRespawn",
2064            "ReplaceImmediate",
2065        ] {
2066            let err = ReturnPolicy::from_str(bad).unwrap_err();
2067            assert_eq!(err.0, bad, "error payload should echo input verbatim");
2068        }
2069    }
2070
2071    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2072    /// documented per-variant on-release behavior.
2073    #[test]
2074    fn return_policy_predicate_truth_tables() {
2075        assert!(!ReturnPolicy::Replace.keeps_process());
2076        assert!(!ReturnPolicy::Replace.runs_reset_job());
2077
2078        assert!(ReturnPolicy::Reset.keeps_process());
2079        assert!(ReturnPolicy::Reset.runs_reset_job());
2080
2081        assert!(ReturnPolicy::Keep.keeps_process());
2082        assert!(!ReturnPolicy::Keep.runs_reset_job());
2083    }
2084
2085    /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
2086    /// You cannot run a typed `:reset` Job against a Process you've
2087    /// just deleted; the impossible bucket `(false, true)` must stay
2088    /// empty. A future variant returning true from `runs_reset_job`
2089    /// while returning false from `keeps_process` fails here, which
2090    /// forces the author to either flip `keeps_process` to true or
2091    /// extend the consumer dispatch site in
2092    /// `tatara-pool-reconciler::return_policy::plan_return`
2093    /// deliberately rather than letting an impossible state slip in.
2094    #[test]
2095    fn return_policy_reset_implies_keeps_process() {
2096        for policy in ReturnPolicy::ALL {
2097            if policy.runs_reset_job() {
2098                assert!(
2099                    policy.keeps_process(),
2100                    "{policy:?} runs a reset job but does not keep the process",
2101                );
2102            }
2103        }
2104    }
2105
2106    /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
2107    /// is injective across `ALL`. Each variant projects to its own
2108    /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
2109    /// `(true, true)` = reset-in-place; `(true, false)` = keep for
2110    /// inspection. Pairing this with the implication contract above
2111    /// forces a future variant to land in a fresh
2112    /// `(keeps_process, runs_reset_job)` bucket — or the author
2113    /// extends the consumer dispatch in
2114    /// `tatara-pool-reconciler::return_policy::plan_return` to
2115    /// recognize the new projection bucket.
2116    #[test]
2117    fn return_policy_predicate_pair_is_injective() {
2118        let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
2119            .into_iter()
2120            .map(|p| (p.keeps_process(), p.runs_reset_job()))
2121            .collect();
2122        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
2123        assert_eq!(
2124            projections.len(),
2125            unique.len(),
2126            "predicate pair projection is not injective: {projections:?}",
2127        );
2128    }
2129
2130    /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
2131    /// the variant tagged `#[default]` in the enum, AND that variant
2132    /// is the safe "tear down + respawn" behavior — neither keeps the
2133    /// process nor runs a reset Job. A future `#[default]` rename
2134    /// without flipping the predicates fails here.
2135    #[test]
2136    fn return_policy_default_is_replace_and_neither_predicate_fires() {
2137        let d = ReturnPolicy::default();
2138        assert_eq!(d, ReturnPolicy::Replace);
2139        assert!(!d.keeps_process());
2140        assert!(!d.runs_reset_job());
2141    }
2142
2143    // ── closed-set algebra contracts for MemberState
2144    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
2145
2146    /// Structural well-formedness of [`MemberState`] as a
2147    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2148    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
2149    /// and [`return_policy_is_well_formed_closed_set`] above.
2150    #[test]
2151    fn member_state_is_well_formed_closed_set() {
2152        tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
2153    }
2154
2155    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2156    /// output verbatim for every variant. A future variant rename (or
2157    /// an `as_str` arm typo) lands here at one site, instead of
2158    /// drifting between the typed surface, the CRD enum, and the YAML
2159    /// wire format the pool reconciler stamps on
2160    /// `status.members[].state`.
2161    #[test]
2162    fn member_state_as_str_matches_serde() {
2163        crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
2164    }
2165
2166    /// The Display impl IS `as_str` — pinning this lets future callers
2167    /// reach for either projection without drift. Any operator-facing
2168    /// "state={state}" diagnostic that composes through Display
2169    /// inherits the canonical wire-format string automatically.
2170    #[test]
2171    fn member_state_display_matches_as_str() {
2172        crate::tagged_union::assert_display_matches_label::<MemberState>();
2173    }
2174
2175    /// `FromStr` rejects strings that aren't in the canonical
2176    /// projection — lowercased / typo / cross-axis-leaked — and
2177    /// the error echoes the input verbatim so the operator-facing
2178    /// diagnostic carries the offending value, not a normalized form.
2179    /// The empty-input arm is pinned by
2180    /// [`member_state_is_well_formed_closed_set`] via the
2181    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
2182    /// pin the closed-set REJECTION contract that the trait can't see:
2183    /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
2184    /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
2185    /// `ProcessPhase`) but MUST reject here, because the codomains
2186    /// are disjoint.
2187    #[test]
2188    fn unknown_member_state_errors() {
2189        for bad in [
2190            "free",
2191            "SPAWNING",
2192            "Free-State",
2193            "allocated_now",
2194            "ReplaceImmediate", // ReplacementPolicy-axis leak
2195            "Reset",            // ReturnPolicy-axis leak
2196            "Attested",         // ProcessPhase-axis leak
2197        ] {
2198            let err = MemberState::from_str(bad).unwrap_err();
2199            assert_eq!(err.0, bad, "error payload should echo input verbatim");
2200        }
2201    }
2202
2203    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2204    /// documented per-variant lifecycle role. The pool reconciler's
2205    /// `pool_phase_from_members` supply calc collapses
2206    /// `count_state(Free) + count_state(Spawning)` into one
2207    /// `counts_toward_supply` filter; this table pins the per-variant
2208    /// projection that consumer depends on.
2209    #[test]
2210    fn member_state_predicate_truth_tables() {
2211        assert!(!MemberState::Spawning.is_failed());
2212        assert!(MemberState::Spawning.counts_toward_supply());
2213
2214        assert!(!MemberState::Free.is_failed());
2215        assert!(MemberState::Free.counts_toward_supply());
2216
2217        assert!(!MemberState::Allocated.is_failed());
2218        assert!(!MemberState::Allocated.counts_toward_supply());
2219
2220        assert!(!MemberState::Returning.is_failed());
2221        assert!(!MemberState::Returning.counts_toward_supply());
2222
2223        assert!(MemberState::Failed.is_failed());
2224        assert!(!MemberState::Failed.counts_toward_supply());
2225    }
2226
2227    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2228    /// `is_failed` and `counts_toward_supply` simultaneously — a
2229    /// failed member can never be counted as available capacity. A
2230    /// future variant that returned true from both would FAIL here,
2231    /// forcing the author to either drop it from supply, or extend
2232    /// the consumer's bucketing in
2233    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
2234    /// deliberately rather than silently inflating the pool's supply
2235    /// count with failed slots.
2236    #[test]
2237    fn member_state_failed_implies_no_supply() {
2238        for state in MemberState::ALL {
2239            assert!(
2240                !(state.is_failed() && state.counts_toward_supply()),
2241                "{state:?} returns true from both is_failed and counts_toward_supply — \
2242                 a failed member can never be counted as available pool capacity",
2243            );
2244        }
2245    }
2246
2247    /// COVERAGE CONTRACT: every variant lands somewhere — either
2248    /// in supply, or as a failed slot, or as an in-use bucket
2249    /// (`Allocated | Returning`). A future variant that returns
2250    /// `false` from `counts_toward_supply` AND `false` from
2251    /// `is_failed` is fine *iff* it represents an in-use slot; this
2252    /// test pins the existing variants in their declared buckets so
2253    /// the consumer-side dispatch in
2254    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
2255    /// stays grounded.
2256    #[test]
2257    fn member_state_buckets_cover_every_variant() {
2258        let mut supply = 0u32;
2259        let mut failed = 0u32;
2260        let mut in_use = 0u32;
2261        for state in MemberState::ALL {
2262            match (state.is_failed(), state.counts_toward_supply()) {
2263                (true, false) => failed += 1,
2264                (false, true) => supply += 1,
2265                (false, false) => in_use += 1,
2266                (true, true) => panic!("disjointness already pins this empty for {state:?}"),
2267            }
2268        }
2269        assert_eq!(supply, 2, "supply bucket: Free + Spawning");
2270        assert_eq!(failed, 1, "failed bucket: Failed");
2271        assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
2272        assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
2273    }
2274
2275    // ── closed-set algebra contracts for PoolPhase
2276    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
2277
2278    /// Structural well-formedness of [`PoolPhase`] as a
2279    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2280    /// symmetric to [`member_state_is_well_formed_closed_set`] above.
2281    #[test]
2282    fn pool_phase_is_well_formed_closed_set() {
2283        tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
2284    }
2285
2286    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2287    /// output verbatim for every variant. A future variant rename (or
2288    /// an `as_str` arm typo) lands here at one site, instead of
2289    /// drifting between the typed surface, the CRD enum, and the YAML
2290    /// wire format the pool reconciler stamps on `status.phase`.
2291    #[test]
2292    fn pool_phase_as_str_matches_serde() {
2293        crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
2294    }
2295
2296    /// The Display impl IS `as_str` — pinning this lets future callers
2297    /// reach for either projection without drift. Any operator-facing
2298    /// "phase={phase}" diagnostic that composes through Display
2299    /// inherits the canonical wire-format string automatically.
2300    #[test]
2301    fn pool_phase_display_matches_as_str() {
2302        crate::tagged_union::assert_display_matches_label::<PoolPhase>();
2303    }
2304
2305    /// `FromStr` rejects strings that aren't in the canonical
2306    /// projection — lowercased / typo / cross-axis-leaked — and
2307    /// the error echoes the input verbatim so the operator-facing
2308    /// diagnostic carries the offending value, not a normalized form.
2309    /// The empty-input arm is pinned by
2310    /// [`pool_phase_is_well_formed_closed_set`] via the
2311    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
2312    /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
2313    /// closed-set REJECTION contract that the trait can't see — those
2314    /// are valid sibling-axis labels but MUST reject here.
2315    #[test]
2316    fn unknown_pool_phase_errors() {
2317        for bad in [
2318            "steady",
2319            "SCALINGUP",
2320            "Scaling-Up",
2321            "scaling_down",
2322            "Free",       // MemberState-axis leak
2323            "Replace",    // ReturnPolicy-axis leak
2324            "Attested",   // ProcessPhase-axis leak
2325            "HoldFailed", // ReplacementPolicy-axis leak
2326        ] {
2327            let err = PoolPhase::from_str(bad).unwrap_err();
2328            assert_eq!(err.0, bad, "error payload should echo input verbatim");
2329        }
2330    }
2331
2332    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2333    /// documented per-variant lifecycle role. Pinning this table at
2334    /// one site means any future status-aggregator surface
2335    /// (`feira pool list --healthy`, the SSE filter, the desired-loop
2336    /// heartbeat short-circuit) reads the same projection that the
2337    /// reconciler writes.
2338    #[test]
2339    fn pool_phase_predicate_truth_tables() {
2340        assert!(!PoolPhase::Initializing.is_steady());
2341        assert!(!PoolPhase::Initializing.is_terminal());
2342
2343        assert!(PoolPhase::Steady.is_steady());
2344        assert!(!PoolPhase::Steady.is_terminal());
2345
2346        assert!(!PoolPhase::ScalingUp.is_steady());
2347        assert!(!PoolPhase::ScalingUp.is_terminal());
2348
2349        assert!(!PoolPhase::ScalingDown.is_steady());
2350        assert!(!PoolPhase::ScalingDown.is_terminal());
2351
2352        assert!(!PoolPhase::Degraded.is_steady());
2353        assert!(!PoolPhase::Degraded.is_terminal());
2354
2355        assert!(!PoolPhase::Draining.is_steady());
2356        assert!(PoolPhase::Draining.is_terminal());
2357    }
2358
2359    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2360    /// `is_steady` and `is_terminal` simultaneously — a draining pool
2361    /// is by definition transitioning OUT, not the goal converged
2362    /// state. A future variant that returned true from both would
2363    /// FAIL here, forcing the author to either pick one bucket or
2364    /// extend the consumer dispatch sites (status aggregators,
2365    /// heartbeat short-circuit) deliberately rather than silently
2366    /// double-firing both branches.
2367    #[test]
2368    fn pool_phase_steady_excludes_terminal() {
2369        for phase in PoolPhase::ALL {
2370            assert!(
2371                !(phase.is_steady() && phase.is_terminal()),
2372                "{phase:?} returns true from both is_steady and is_terminal — \
2373                 a draining pool is by definition not the converged goal state",
2374            );
2375        }
2376    }
2377
2378    /// COVERAGE CONTRACT: every variant lands somewhere — either the
2379    /// converged goal (`Steady`), the absorbing exit (`Draining`),
2380    /// or the transient bucket (`Initializing | ScalingUp |
2381    /// ScalingDown | Degraded` — pool is in motion toward desired).
2382    /// A future variant that returns `false` from BOTH predicates is
2383    /// fine *iff* it represents an in-motion state; this test pins
2384    /// the existing variants in their declared buckets so the
2385    /// projection consumers stay grounded.
2386    #[test]
2387    fn pool_phase_buckets_cover_every_variant() {
2388        let mut converged = 0u32;
2389        let mut terminal = 0u32;
2390        let mut transient = 0u32;
2391        for phase in PoolPhase::ALL {
2392            match (phase.is_steady(), phase.is_terminal()) {
2393                (true, false) => converged += 1,
2394                (false, true) => terminal += 1,
2395                (false, false) => transient += 1,
2396                (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
2397            }
2398        }
2399        assert_eq!(converged, 1, "converged bucket: Steady");
2400        assert_eq!(terminal, 1, "terminal bucket: Draining");
2401        assert_eq!(
2402            transient, 4,
2403            "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
2404        );
2405        assert_eq!(
2406            converged + terminal + transient,
2407            PoolPhase::ALL.len() as u32
2408        );
2409    }
2410
2411    /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
2412    /// variant a freshly-admitted pool should land in — `Initializing`
2413    /// — AND that variant is neither steady (no members yet) nor
2414    /// terminal (not deletion-stamped). A future `Default` rename
2415    /// without flipping the predicates fails here.
2416    #[test]
2417    fn pool_phase_default_is_initializing_in_transient_bucket() {
2418        let d = PoolPhase::default();
2419        assert_eq!(d, PoolPhase::Initializing);
2420        assert!(!d.is_steady());
2421        assert!(!d.is_terminal());
2422    }
2423
2424    // ─────────────────────────────────────────────────────────────────
2425    // `EphemeralPool::name_or_empty` — borrow-form metadata-projection
2426    // primitive on the `metadata.name` axis. Pins the missing-slot
2427    // corner, the populated-slot corner, the pre-lift chain-shape
2428    // parity, and the pure-projection discipline that the two
2429    // `tatara-pool-reconciler` consumers routed onto the primitive
2430    // depend on. See the primitive's doc-comment for the full
2431    // migration rationale.
2432    // ─────────────────────────────────────────────────────────────────
2433
2434    fn empty_template() -> EphemeralSpec {
2435        EphemeralSpec {
2436            aplicacao: crate::intent::AplicacaoIntent {
2437                chart_ref: "oci://x".into(),
2438                version: "1".into(),
2439                profile: String::new(),
2440                values_overlay: serde_json::Value::Null,
2441                release_name: None,
2442                target_namespace: None,
2443                install_timeout: None,
2444            },
2445            ttl: "1h".into(),
2446            teardown: crate::lifetime::TeardownPolicy::Always,
2447            max_concurrent: 0,
2448            postconditions: vec![],
2449            preconditions: vec![],
2450            verify_timeout: None,
2451            classification: None,
2452            parent: None,
2453            exports: vec![],
2454            routing: None,
2455        }
2456    }
2457
2458    fn pool_spec() -> PoolSpec {
2459        PoolSpec {
2460            desired_size: 1,
2461            min_size: 0,
2462            max_size: 0,
2463            return_policy: ReturnPolicy::Replace,
2464            selector: PoolSelector::default(),
2465            template: empty_template(),
2466            free_ttl: "24h".into(),
2467            max_allocation_ttl: "4h".into(),
2468            desired: 0,
2469            replacement_policy: ReplacementPolicy::default(),
2470            stable_name_claim: false,
2471        }
2472    }
2473
2474    fn pool_named(name: &str) -> EphemeralPool {
2475        EphemeralPool::new(name, pool_spec())
2476    }
2477
2478    fn pool_unnamed() -> EphemeralPool {
2479        let mut p = EphemeralPool::new("scratch", pool_spec());
2480        p.metadata.name = None;
2481        p
2482    }
2483
2484    #[test]
2485    fn name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2486        let p = pool_unnamed();
2487        assert!(p.metadata.name.is_none(), "fixture invariant");
2488        assert_eq!(p.name_or_empty(), "");
2489    }
2490
2491    #[test]
2492    fn name_or_empty_returns_populated_slot_verbatim() {
2493        let p = pool_named("attest-pool");
2494        assert_eq!(p.name_or_empty(), "attest-pool");
2495    }
2496
2497    #[test]
2498    fn name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2499        // Corner between `None` (missing slot) and `Some(String::new())`
2500        // (populated slot containing the empty string): the primitive
2501        // MUST fold both to the same `""` byte-shape so a downstream
2502        // `HashMap<String,_>::get(name)` / `str::cmp` sees ONE
2503        // "unnamed pool" bucket regardless of which shape the K8s API
2504        // server materialized. This is byte-identical to what the
2505        // pre-lift `.as_deref().unwrap_or("")` chain produced.
2506        let mut p = pool_named("scratch");
2507        p.metadata.name = Some(String::new());
2508        assert_eq!(p.name_or_empty(), "");
2509    }
2510
2511    #[test]
2512    fn name_or_empty_is_a_pure_projection() {
2513        // Consecutive calls return byte-identical slices — no cached
2514        // state, no mutation on the `EphemeralPool` between calls.
2515        // Guards against a future refactor that plants a cache field
2516        // and drifts one caller from another silently.
2517        let p = pool_named("router-pool");
2518        assert_eq!(p.name_or_empty(), p.name_or_empty());
2519        assert_eq!(p.name_or_empty(), "router-pool");
2520        assert_eq!(p.name_or_empty(), "router-pool");
2521    }
2522
2523    #[test]
2524    fn name_or_empty_matches_pre_lift_chain_verbatim() {
2525        // Byte-identical parity with the two hand-authored
2526        // `.metadata.name.as_deref().unwrap_or("")` chains the
2527        // primitive replaces in `tatara-pool-reconciler::router` and
2528        // `tatara-pool-reconciler::controller_allocation`. Runs across
2529        // the FULL corner set of the metadata.name slot: absent,
2530        // present-with-value, present-with-empty-string.
2531        let cases: [(Option<String>, &str); 3] = [
2532            (None, ""),
2533            (Some("attest-pool".into()), "attest-pool"),
2534            (Some(String::new()), ""),
2535        ];
2536        for (slot, expected) in cases {
2537            let mut p = pool_named("scratch");
2538            p.metadata.name = slot.clone();
2539            let pre_lift = p.metadata.name.as_deref().unwrap_or("");
2540            assert_eq!(pre_lift, expected, "pre-lift chain sanity");
2541            assert_eq!(p.name_or_empty(), pre_lift);
2542            assert_eq!(p.name_or_empty(), expected);
2543        }
2544    }
2545
2546    #[test]
2547    fn name_or_empty_borrows_from_metadata_name_slot() {
2548        // The returned `&str` is tied to the `EphemeralPool`'s
2549        // lifetime — the caller can compare / hash / index without
2550        // allocating. This is the load-bearing property that lets
2551        // the `HashMap<String, _>::get(pool.name_or_empty())` closure
2552        // in `controller_allocation::reconcile_inner` skip cloning.
2553        let p = pool_named("attest-pool");
2554        let s: &str = p.name_or_empty();
2555        assert_eq!(s.as_ptr(), p.metadata.name.as_deref().unwrap().as_ptr());
2556    }
2557
2558    // ─── EphemeralPool::owned_name_or_empty substrate pins ────────────
2559    //
2560    // The owned-form peer of the borrow-form `name_or_empty` primitive
2561    // above. Sibling to the sister-CRD primitive
2562    // `crate::crd::Process::owned_name_or_empty` (owned + empty sentinel
2563    // on `Process::metadata.name`) — the four primitives now partition
2564    // the (borrow × owned) × (name × uid) corner of the metadata-slot
2565    // family on identical missing-slot semantics across BOTH tatara-
2566    // process CRDs (`Process::uid_or_empty` + `Process::owned_name_or_empty`
2567    // + `EphemeralPool::name_or_empty` + this method). Fail-before-pass-
2568    // after granularity: `owned_name_or_empty` did not exist on the pool
2569    // CRD pre-lift; the compiler cannot resolve the name until the impl
2570    // block above is in place, so a rollback of the primitive breaks
2571    // this whole module.
2572    #[test]
2573    fn owned_name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2574        let p = pool_unnamed();
2575        assert!(p.metadata.name.is_none(), "fixture invariant");
2576        assert_eq!(p.owned_name_or_empty(), String::new());
2577    }
2578
2579    #[test]
2580    fn owned_name_or_empty_returns_owned_string_when_slot_is_populated() {
2581        let p = pool_named("attest-pool");
2582        assert_eq!(p.owned_name_or_empty(), "attest-pool");
2583    }
2584
2585    #[test]
2586    fn owned_name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2587        // Corner between `None` (missing slot) and `Some(String::new())`
2588        // (populated slot containing the empty string): the primitive
2589        // MUST fold both to the same `""` byte-shape so a downstream
2590        // `HashMap<String,_>::get(name)` sees ONE "unnamed pool" bucket
2591        // regardless of which shape the K8s API server materialized.
2592        // Byte-identical to what the pre-lift `.clone().unwrap_or_default()`
2593        // chain produced.
2594        let mut p = pool_named("scratch");
2595        p.metadata.name = Some(String::new());
2596        assert_eq!(p.owned_name_or_empty(), String::new());
2597        assert!(p.owned_name_or_empty().is_empty());
2598    }
2599
2600    #[test]
2601    fn owned_name_or_empty_is_a_pure_projection() {
2602        // Consecutive calls return byte-identical Strings — no cached
2603        // state, no mutation on the `EphemeralPool` between calls.
2604        // Guards against a future refactor that plants a cache field
2605        // and drifts one caller from another silently.
2606        let p = pool_named("router-pool");
2607        assert_eq!(p.owned_name_or_empty(), p.owned_name_or_empty());
2608        assert_eq!(p.owned_name_or_empty(), "router-pool");
2609        assert_eq!(p.owned_name_or_empty(), "router-pool");
2610    }
2611
2612    #[test]
2613    fn owned_name_or_empty_matches_pre_lift_chain_verbatim() {
2614        // Byte-identical parity with the two hand-authored
2615        // `.metadata.name.clone().unwrap_or_default()` chains the
2616        // primitive replaces in `tatara-pool-reconciler::
2617        // controller_allocation::reconcile_inner` (HashMap key seed)
2618        // and `tatara-pool-reconciler::allocation_decide::
2619        // AllocationConvergenceCtx::observe` (AllocationRef.name slot
2620        // seed). Runs across the FULL corner set of the metadata.name
2621        // slot: absent, present-with-value, present-with-empty-string.
2622        // A regression that inserted a normalization step at the
2623        // primitive the pre-lift chain does NOT apply — or vice versa —
2624        // surfaces here rather than as silent drift between the two
2625        // owned-form callsites and the ONE substrate owner they now
2626        // route through.
2627        let cases: [(Option<String>, &str); 3] = [
2628            (None, ""),
2629            (Some("attest-pool".into()), "attest-pool"),
2630            (Some(String::new()), ""),
2631        ];
2632        for (slot, expected) in cases {
2633            let mut p = pool_named("scratch");
2634            p.metadata.name = slot.clone();
2635            let pre_lift = p.metadata.name.clone().unwrap_or_default();
2636            assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2637            assert_eq!(p.owned_name_or_empty(), pre_lift);
2638            assert_eq!(p.owned_name_or_empty().as_str(), expected);
2639        }
2640    }
2641
2642    #[test]
2643    fn owned_name_or_empty_matches_borrow_form_peer_on_populated_slot() {
2644        // Cross-primitive coherence pin at the sibling corner: when the
2645        // slot is present, the borrow-form (`name_or_empty`) and owned-
2646        // form (`owned_name_or_empty`) primitives return the SAME byte
2647        // sequence and differ only in ownership. A regression that
2648        // skewed one form's fallback would surface here rather than as
2649        // silent drift between the router tie-break comparator and the
2650        // AllocationRef seed on the SAME pool.
2651        let p = pool_named("attest-pool");
2652        assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2653    }
2654
2655    #[test]
2656    fn owned_name_or_empty_matches_borrow_form_peer_on_missing_slot() {
2657        // Sibling corner of the coherence pin above: when the slot is
2658        // absent (or explicitly empty), BOTH primitives fold to the
2659        // same empty-string byte-shape. The load-bearing property is
2660        // that a caller who switches between the two return-forms
2661        // based on downstream ownership requirements never sees a
2662        // different missing-slot spelling as a side effect.
2663        let p = pool_unnamed();
2664        assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2665        assert_eq!(p.name_or_empty(), "");
2666        assert_eq!(p.owned_name_or_empty(), String::new());
2667    }
2668
2669    // ─── EphemeralPool::is_being_deleted substrate pins ───────────────
2670    //
2671    // Pins the copy-form metadata-projection primitive on the deletion-
2672    // tombstone axis of the pool CRD. Peer to the borrow-form + owned-
2673    // form metadata-fallback family (`name_or_empty`,
2674    // `owned_name_or_empty`); this one opens the presence-probe corner
2675    // for the tombstone slot. Sibling to the sister-CRD primitive
2676    // `crate::crd::Process::is_being_deleted` — the two primitives
2677    // now partition the tombstone-presence probe across BOTH tatara-
2678    // process CRDs on identical missing-slot semantics. Fail-before-
2679    // pass-after granularity: `is_being_deleted` did not exist on the
2680    // pool CRD pre-lift; the compiler cannot resolve the name until
2681    // the impl block above is in place, so a rollback of the primitive
2682    // breaks this whole module.
2683
2684    fn tombstoned_pool() -> EphemeralPool {
2685        let mut p = pool_named("attest-pool");
2686        p.metadata.namespace = Some("ephemeral-pools".into());
2687        p.metadata.deletion_timestamp = Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2688            Utc::now(),
2689        ));
2690        p
2691    }
2692
2693    #[test]
2694    fn is_being_deleted_returns_false_when_deletion_timestamp_is_absent() {
2695        // Missing-tombstone corner pin: the primitive collapses the
2696        // no-tombstone case to `false` so the `→ Drain` short-circuit
2697        // at `decide_pool_reconcile` is NOT taken and the observed-
2698        // phase composer at `pool_phase_from_members` proceeds to its
2699        // normal (free / spawning / allocated) arithmetic branches
2700        // instead of short-circuiting to `PoolPhase::Draining`.
2701        // Matches the pre-lift `.is_some()` chain's `false` byte-
2702        // identically at every consumer's downstream gate.
2703        let mut p = pool_named("attest-pool");
2704        p.metadata.deletion_timestamp = None;
2705        assert!(!p.is_being_deleted());
2706    }
2707
2708    #[test]
2709    fn is_being_deleted_returns_true_when_deletion_timestamp_is_present() {
2710        // Present-tombstone corner pin: the primitive returns `true`
2711        // on any populated `metadata.deletionTimestamp` slot regardless
2712        // of the timestamp payload — the two consumers only read the
2713        // tombstone's PRESENCE, never its RFC-3339 timestamp value.
2714        // A regression that gated the `true` return on the timestamp
2715        // being non-epoch, or parsed the timestamp before returning,
2716        // would surface here rather than as silent skew at the
2717        // `→ Drain` decision or the `→ Draining` phase report on the
2718        // SAME `EphemeralPool`.
2719        let p = tombstoned_pool();
2720        assert!(p.is_being_deleted());
2721    }
2722
2723    #[test]
2724    fn is_being_deleted_is_a_pure_projection() {
2725        // Purity pin: two consecutive calls return byte-identical
2726        // `bool` values (no lazy materialization, no interior
2727        // mutation of `self`). Peer to the sibling
2728        // `name_or_empty_is_a_pure_projection` +
2729        // `owned_name_or_empty_is_a_pure_projection` pins in this
2730        // module and to `is_being_deleted_is_a_pure_projection` on
2731        // the sister-CRD `Process`; all four bind the pure-projection
2732        // discipline on the ONE substrate accessor per metadata slot.
2733        let p = tombstoned_pool();
2734        let a = p.is_being_deleted();
2735        let b = p.is_being_deleted();
2736        assert_eq!(a, b);
2737        assert!(a);
2738    }
2739
2740    #[test]
2741    fn is_being_deleted_matches_pre_lift_pool_reconciler_chain_shape() {
2742        // Parity pin: sweeps the two corners every pre-lift consumer
2743        // plausibly encountered (missing tombstone, present tombstone)
2744        // and compares the substrate call against a hand-authored pre-
2745        // lift chain byte-identically. A regression that reshaped
2746        // either corner would surface here rather than as silent
2747        // operator-facing skew between the pool-reconciler's `→ Drain`
2748        // decision and the observed-phase composer's `→ Draining`
2749        // report on the SAME `EphemeralPool` within one reconcile
2750        // pass.
2751        fn pre_lift(p: &EphemeralPool) -> bool {
2752            p.metadata.deletion_timestamp.is_some()
2753        }
2754        // Missing slot.
2755        let mut p = pool_named("attest-pool");
2756        p.metadata.deletion_timestamp = None;
2757        assert_eq!(p.is_being_deleted(), pre_lift(&p));
2758        // Populated slot.
2759        let p = tombstoned_pool();
2760        assert_eq!(p.is_being_deleted(), pre_lift(&p));
2761    }
2762
2763    #[test]
2764    fn is_being_deleted_composes_with_pool_phase_draining_at_reconcile_preempt() {
2765        // Call-site-shape pin: the `pool_phase_from_members`
2766        // deletion-preempt returns `PoolPhase::Draining` as soon as
2767        // `pool.is_being_deleted()` holds, regardless of the (free +
2768        // spawning) supply arithmetic that would otherwise pick
2769        // `Ready` / `Scaling` / `Degraded`. The `→ Drain` decision at
2770        // `decide_pool_reconcile` composes with the same probe on the
2771        // same tombstone-presence slot. A regression that broadened
2772        // the tombstone probe implicitly (returning `false` on a
2773        // present but zero-timestamp) or narrowed it (requiring an
2774        // additional `.finalizers.is_empty()` conjunct that the two
2775        // consumers never spelled) would surface here rather than as
2776        // silent operator-facing skew between the pool reconciler's
2777        // decision and the observed-phase composer on the SAME
2778        // `EphemeralPool` within one reconcile pass.
2779        let alive = pool_named("attest-pool");
2780        assert!(!alive.is_being_deleted());
2781        let dying = tombstoned_pool();
2782        assert!(dying.is_being_deleted());
2783    }
2784
2785    // ─── EphemeralPool::owned_namespace_or_empty substrate pins ───────
2786    //
2787    // The owned-form peer of the `owned_name_or_empty` primitive on the
2788    // sibling `metadata.namespace` axis — the paired half of the
2789    // `AllocationRef { name, namespace }` struct literal both
2790    // `AllocationConvergenceCtx::observe` and the composition pin
2791    // consume through the SAME `AllocationRef::new(name, namespace)`
2792    // constructor. Fail-before-pass-after granularity:
2793    // `owned_namespace_or_empty` did not exist on the pool CRD pre-
2794    // lift; the compiler cannot resolve the name until the impl block
2795    // above is in place, so a rollback of the primitive breaks this
2796    // whole module.
2797    #[test]
2798    fn owned_namespace_or_empty_returns_empty_string_when_metadata_namespace_is_none() {
2799        // Missing-slot corner pin: the primitive collapses the no-
2800        // namespace case to the load-bearing empty-string sentinel so
2801        // the downstream `AllocationRef.namespace` slot carries `""`
2802        // rather than a defaulted `"default"` string. See the doc-
2803        // comment's DELIBERATE-EMPTY-SENTINEL rationale for why the
2804        // fallback matches `.clone().unwrap_or_default()` byte-for-
2805        // byte rather than substituting `Process::DEFAULT_NAMESPACE`
2806        // at the primitive.
2807        let mut p = pool_named("attest-pool");
2808        p.metadata.namespace = None;
2809        assert!(p.metadata.namespace.is_none(), "fixture invariant");
2810        assert_eq!(p.owned_namespace_or_empty(), String::new());
2811    }
2812
2813    #[test]
2814    fn owned_namespace_or_empty_returns_owned_string_when_slot_is_populated() {
2815        let mut p = pool_named("attest-pool");
2816        p.metadata.namespace = Some("ephemeral-pools".into());
2817        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2818    }
2819
2820    #[test]
2821    fn owned_namespace_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2822        // Corner between `None` (missing slot) and `Some(String::new())`
2823        // (populated slot containing the empty string): the primitive
2824        // MUST fold both to the same `""` byte-shape so a downstream
2825        // `AllocationRef.namespace ==` comparator at
2826        // `resolve_pool` sees ONE "unset namespace" bucket regardless
2827        // of which shape the K8s API server materialized. Byte-
2828        // identical to what the pre-lift `.clone().unwrap_or_default()`
2829        // chain produced.
2830        let mut p = pool_named("attest-pool");
2831        p.metadata.namespace = Some(String::new());
2832        assert_eq!(p.owned_namespace_or_empty(), String::new());
2833        assert!(p.owned_namespace_or_empty().is_empty());
2834    }
2835
2836    #[test]
2837    fn owned_namespace_or_empty_is_a_pure_projection() {
2838        // Consecutive calls return byte-identical Strings — no cached
2839        // state, no mutation on the `EphemeralPool` between calls.
2840        // Peer to the sibling `owned_name_or_empty_is_a_pure_projection`
2841        // pin in this module and to `is_being_deleted_is_a_pure_projection`
2842        // on the same CRD; all three bind the pure-projection
2843        // discipline on the ONE substrate accessor per metadata slot.
2844        let mut p = pool_named("attest-pool");
2845        p.metadata.namespace = Some("ephemeral-pools".into());
2846        assert_eq!(p.owned_namespace_or_empty(), p.owned_namespace_or_empty());
2847        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2848        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2849    }
2850
2851    #[test]
2852    fn owned_namespace_or_empty_matches_pre_lift_chain_verbatim() {
2853        // Byte-identical parity with the two hand-authored
2854        // `.metadata.namespace.clone().unwrap_or_default()` chains
2855        // the primitive replaces in `tatara-pool-reconciler::
2856        // allocation_decide::AllocationConvergenceCtx::observe`
2857        // (matched-pool `AllocationRef.namespace` seed) and in the
2858        // sibling composition pin
2859        // `allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`.
2860        // Runs across the FULL corner set of the metadata.namespace
2861        // slot: absent, present-with-value, present-with-empty-string.
2862        // A regression that inserted a normalization step at the
2863        // primitive the pre-lift chain does NOT apply — or vice versa —
2864        // surfaces here rather than as silent drift between the two
2865        // owned-form callsites and the ONE substrate owner they now
2866        // route through.
2867        let cases: [(Option<String>, &str); 3] = [
2868            (None, ""),
2869            (Some("ephemeral-pools".into()), "ephemeral-pools"),
2870            (Some(String::new()), ""),
2871        ];
2872        for (slot, expected) in cases {
2873            let mut p = pool_named("attest-pool");
2874            p.metadata.namespace = slot.clone();
2875            let pre_lift = p.metadata.namespace.clone().unwrap_or_default();
2876            assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2877            assert_eq!(p.owned_namespace_or_empty(), pre_lift);
2878            assert_eq!(p.owned_namespace_or_empty().as_str(), expected);
2879        }
2880    }
2881
2882    #[test]
2883    fn owned_namespace_or_empty_composes_with_owned_name_or_empty_on_paired_slot_axis() {
2884        // Paired-axis coherence pin: the two owned-form primitives on
2885        // the pool CRD's `metadata.name` + `metadata.namespace` slots
2886        // share the SAME empty-string sentinel on the missing corner,
2887        // so a caller that composes both halves into an
2888        // `AllocationRef` (as `AllocationConvergenceCtx::observe`
2889        // does) never sees a mixed-fallback pair (one `""`, the
2890        // other `"default"`) as a side effect of one slot being
2891        // absent. A regression that skewed either primitive's
2892        // fallback would surface here rather than as silent operator-
2893        // facing skew between the paired halves of the SAME
2894        // `AllocationRef` seed.
2895        let mut p = pool_named("attest-pool");
2896        p.metadata.namespace = None;
2897        p.metadata.name = None;
2898        assert_eq!(p.owned_name_or_empty(), p.owned_namespace_or_empty());
2899        assert_eq!(p.owned_name_or_empty(), String::new());
2900        assert_eq!(p.owned_namespace_or_empty(), String::new());
2901    }
2902
2903    #[test]
2904    fn owned_namespace_or_empty_does_not_default_to_process_default_namespace() {
2905        // Deliberate-empty-sentinel pin: the primitive's fallback is
2906        // `""`, NOT `crate::crd::Process::DEFAULT_NAMESPACE`. The
2907        // sole downstream consumer (`AllocationConvergenceCtx::observe`)
2908        // feeds the produced value into `AllocationRef.namespace`,
2909        // which is then matched byte-identically against
2910        // `spec.pool_ref.namespace` at `resolve_pool`. A silent
2911        // substitution of `"default"` at this primitive would alias
2912        // every namespace-absent pool to the `"default"` bucket at
2913        // the matcher, hiding the missing-slot corner from an
2914        // operator who explicitly authored an allocation against a
2915        // namespace-unset pool. Pinned so a future "helpful"
2916        // canonicalization step lands as a compiler-visible failure
2917        // here rather than as silent operator-facing skew at the
2918        // matched-pool seed.
2919        let mut p = pool_named("attest-pool");
2920        p.metadata.namespace = None;
2921        assert_ne!(
2922            p.owned_namespace_or_empty(),
2923            crate::crd::Process::DEFAULT_NAMESPACE
2924        );
2925        assert_eq!(p.owned_namespace_or_empty(), "");
2926    }
2927
2928    // ─── EphemeralPool::owned_uid_or_name_or_empty substrate pins ─────
2929    //
2930    // Pins the compound owned-form projection on the paired
2931    // `(metadata.uid, metadata.name)` axis of the pool CRD — the
2932    // ONE-liner collapse of the paired `.metadata.uid.clone()
2933    // .unwrap_or_else(|| name.<into>())` chain every pool-slot-name
2934    // consumer restated by hand pre-lift at TWO production sites in
2935    // `tatara-pool-reconciler::controller_pool` (spawn arm +
2936    // apply_convergence_actions arm), both feeding the SAME
2937    // `member_process_name(&pool_name, &pool_uid_or_name_fallback,
2938    // slot)` composer. Fail-before-pass-after granularity:
2939    // `owned_uid_or_name_or_empty` did not exist on the pool CRD pre-
2940    // lift; the compiler cannot resolve the name until the impl block
2941    // above is in place, so a rollback of the primitive breaks this
2942    // whole module.
2943    #[test]
2944    fn owned_uid_or_name_or_empty_returns_uid_when_uid_is_present() {
2945        // Preferred-slot pin: uid populated → uid wins, regardless of
2946        // whether the name-fallback slot is populated. Byte-identical
2947        // to what each pre-lift `.metadata.uid.clone().unwrap_or_else
2948        // (|| name.<into>())` chain returned in the reachable-state
2949        // corner where the K8s API server has stamped a uid (the
2950        // common case at both callsites, which are already gated by
2951        // `owned_coordinates_required()?`).
2952        let mut p = pool_named("attest-pool");
2953        p.metadata.uid = Some("uid-42".into());
2954        assert_eq!(p.owned_uid_or_name_or_empty(), "uid-42");
2955    }
2956
2957    #[test]
2958    fn owned_uid_or_name_or_empty_falls_back_to_name_when_uid_is_missing() {
2959        // Fallback-slot pin: uid absent → name wins. Byte-identical
2960        // to what each pre-lift chain returned in the corner where
2961        // the K8s API server has NOT yet stamped a uid (pre-admission
2962        // / unit-test in-memory pool). The pre-lift chain reached
2963        // the fallback via a locally-bound `name` string derived from
2964        // the same `.metadata.name` slot the primitive reaches via
2965        // `owned_name_or_empty()`.
2966        let mut p = pool_named("attest-pool");
2967        p.metadata.uid = None;
2968        assert_eq!(p.owned_uid_or_name_or_empty(), "attest-pool");
2969    }
2970
2971    #[test]
2972    fn owned_uid_or_name_or_empty_sinks_to_empty_when_both_slots_are_missing() {
2973        // Missing-both corner pin: uid absent AND name absent → the
2974        // load-bearing empty-string sentinel. Coherent with the
2975        // sibling primitives `owned_name_or_empty` +
2976        // `owned_namespace_or_empty` on the SAME empty-sentinel axis.
2977        // A regression that dropped either fallback surfaces here
2978        // rather than as a runtime panic on `.unwrap()` at a spawn
2979        // callsite that assumed both slots were populated.
2980        let mut p = pool_named("attest-pool");
2981        p.metadata.uid = None;
2982        p.metadata.name = None;
2983        assert_eq!(p.owned_uid_or_name_or_empty(), String::new());
2984        assert!(p.owned_uid_or_name_or_empty().is_empty());
2985    }
2986
2987    #[test]
2988    fn owned_uid_or_name_or_empty_prefers_uid_when_both_slots_are_present() {
2989        // Precedence pin: both slots populated → uid wins. The pre-
2990        // lift `.unwrap_or_else(|| name.<into>())` chain's short-
2991        // circuit on the `Some(u)` arm skipped the fallback entirely;
2992        // the primitive matches that byte-for-byte via `.clone()
2993        // .unwrap_or_else(|| self.owned_name_or_empty())`, so the
2994        // name-fallback slot is not read when uid is populated.
2995        let mut p = pool_named("attest-pool");
2996        p.metadata.uid = Some("uid-preferred".into());
2997        p.metadata.name = Some("attest-pool".into());
2998        assert_eq!(p.owned_uid_or_name_or_empty(), "uid-preferred");
2999        assert_ne!(p.owned_uid_or_name_or_empty(), "attest-pool");
3000    }
3001
3002    #[test]
3003    fn owned_uid_or_name_or_empty_returns_uid_even_when_uid_is_explicitly_empty_string() {
3004        // Corner between `None` (missing slot) and `Some(String::new())`
3005        // (populated slot containing the empty string): the primitive
3006        // MUST return the populated-empty-string uid rather than
3007        // falling back to the name half — byte-identical to what the
3008        // pre-lift `.metadata.uid.clone().unwrap_or_else(|| name...)`
3009        // chain produced, whose `unwrap_or_else` short-circuits on
3010        // `Some(_)` regardless of the wrapped value. Pinned so a
3011        // future "helpful" canonicalization that treats
3012        // `Some(String::new())` as `None` at the primitive lands as
3013        // a compiler-visible failure here rather than as silent
3014        // operator-facing skew between the two spawn-slot-slug seeds.
3015        let mut p = pool_named("attest-pool");
3016        p.metadata.uid = Some(String::new());
3017        p.metadata.name = Some("attest-pool".into());
3018        assert_eq!(p.owned_uid_or_name_or_empty(), String::new());
3019        assert_ne!(p.owned_uid_or_name_or_empty(), "attest-pool");
3020    }
3021
3022    #[test]
3023    fn owned_uid_or_name_or_empty_is_a_pure_projection() {
3024        // Consecutive calls return byte-identical Strings across the
3025        // FULL corner set (uid-present, uid-absent name-fallback,
3026        // both-absent empty-sentinel) — no cached state, no mutation
3027        // on the `EphemeralPool` between calls. Peer to the sibling
3028        // `owned_name_or_empty_is_a_pure_projection` +
3029        // `owned_namespace_or_empty_is_a_pure_projection` pins in
3030        // this module; all three bind the pure-projection discipline
3031        // on the ONE substrate accessor per metadata-derived slot.
3032        let mut p = pool_named("attest-pool");
3033        p.metadata.uid = Some("uid-42".into());
3034        assert_eq!(
3035            p.owned_uid_or_name_or_empty(),
3036            p.owned_uid_or_name_or_empty()
3037        );
3038        p.metadata.uid = None;
3039        assert_eq!(
3040            p.owned_uid_or_name_or_empty(),
3041            p.owned_uid_or_name_or_empty()
3042        );
3043        p.metadata.name = None;
3044        assert_eq!(
3045            p.owned_uid_or_name_or_empty(),
3046            p.owned_uid_or_name_or_empty()
3047        );
3048    }
3049
3050    #[test]
3051    fn owned_uid_or_name_or_empty_matches_pre_lift_chain_verbatim() {
3052        // Byte-identical parity with the two hand-authored
3053        // `.metadata.uid.clone().unwrap_or_else(|| name.<into>())`
3054        // chains the primitive replaces in
3055        // `tatara-pool-reconciler::controller_pool` (spawn arm +
3056        // apply_convergence_actions arm). Runs across the FULL
3057        // corner set of the paired (metadata.uid, metadata.name)
3058        // slots. A regression that inserted a normalization step at
3059        // the primitive the pre-lift chain does NOT apply — or vice
3060        // versa — surfaces here rather than as silent drift between
3061        // the two owned-form callsites and the ONE substrate owner
3062        // they now route through.
3063        let cases: [(Option<String>, Option<String>, &str); 6] = [
3064            (Some("uid-42".into()), Some("attest-pool".into()), "uid-42"),
3065            (Some("uid-42".into()), None, "uid-42"),
3066            (Some(String::new()), Some("attest-pool".into()), ""),
3067            (None, Some("attest-pool".into()), "attest-pool"),
3068            (None, Some(String::new()), ""),
3069            (None, None, ""),
3070        ];
3071        for (uid_slot, name_slot, expected) in cases {
3072            let mut p = pool_named("attest-pool");
3073            p.metadata.uid = uid_slot.clone();
3074            p.metadata.name = name_slot.clone();
3075            // Reproduce the pre-lift chain shape at the spawn arm
3076            // (fallback `|| name.clone()` on an extracted-earlier
3077            // `String` name) — semantically equivalent to
3078            // `.metadata.name.clone().unwrap_or_default()` at the
3079            // point of call because `owned_coordinates_required()?`
3080            // gate guarantees the caller's `name` binding matches
3081            // the pool's own `metadata.name` slot.
3082            let pre_lift = p
3083                .metadata
3084                .uid
3085                .clone()
3086                .unwrap_or_else(|| p.metadata.name.clone().unwrap_or_default());
3087            assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
3088            assert_eq!(p.owned_uid_or_name_or_empty(), pre_lift);
3089            assert_eq!(p.owned_uid_or_name_or_empty().as_str(), expected);
3090        }
3091    }
3092
3093    #[test]
3094    fn owned_uid_or_name_or_empty_composes_with_member_process_name_seed_shape() {
3095        // Composition pin: the produced owned `String` feeds the
3096        // downstream `member_process_name(&pool_name, &pool_uid_or_
3097        // name_fallback, slot)` composer at both callsites, so the
3098        // seed's `String` shape must survive being borrowed as
3099        // `&str` for the composer without any owned/borrow-form
3100        // adaptation at the callsite. Binds the primitive's return
3101        // type + the borrow-form availability that the pre-lift
3102        // chain also produced (a locally-owned `String` from
3103        // `.clone().unwrap_or_else(|| name.<into>())`).
3104        let mut p = pool_named("attest-pool");
3105        p.metadata.uid = Some("uid-42".into());
3106        let seed: String = p.owned_uid_or_name_or_empty();
3107        let _borrowed: &str = &seed;
3108        assert_eq!(seed, "uid-42");
3109        p.metadata.uid = None;
3110        let seed_fallback: String = p.owned_uid_or_name_or_empty();
3111        let _borrowed_fallback: &str = &seed_fallback;
3112        assert_eq!(seed_fallback, "attest-pool");
3113    }
3114
3115    // ─── AllocationRef::new substrate pins ────────────────────────────
3116    //
3117    // Pins the substrate constructor for [`AllocationRef`] — the
3118    // ONE-liner composer that lifts the paired
3119    // `AllocationRef { name, namespace }` struct-literal every
3120    // downstream consumer restated by hand pre-lift at FOUR production
3121    // sites (2 × controller_allocation.rs assignedProcess seeds, 1 ×
3122    // allocation_decide.rs pool_ref seed, 1 × allocation_factory.rs
3123    // pool_ref seed) onto ONE substrate owner on `AllocationRef`.
3124    // Fail-before-pass-after granularity: `AllocationRef::new` did not
3125    // exist pre-lift; the compiler cannot resolve the name until the
3126    // impl block above is in place, so a rollback of the primitive
3127    // breaks this whole module.
3128
3129    #[test]
3130    fn allocation_ref_new_composes_owned_string_pair_verbatim() {
3131        // Happy-path pin: the constructor materializes an
3132        // `AllocationRef { name: <name>, namespace: <namespace> }`
3133        // byte-identical to the pre-lift struct literal every consumer
3134        // spelled. A regression that dropped either slot (e.g. an
3135        // erroneous `..Default::default()` on a shape that never had
3136        // a Default derive) surfaces here rather than as silent slot
3137        // loss downstream at the assignedProcess / bound_pool /
3138        // matched_pool / spec.pool_ref sinks.
3139        let r = AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
3140        assert_eq!(r.name, "pr-42-demo");
3141        assert_eq!(r.namespace, "ephemeral-pools");
3142    }
3143
3144    #[test]
3145    fn allocation_ref_new_matches_pre_lift_struct_literal_verbatim() {
3146        // Byte-identical parity pin: the substrate constructor and the
3147        // hand-authored struct literal produce equal `AllocationRef`
3148        // values on every provenance the FOUR pre-lift sites carried
3149        // (owned `String` from an owned-form projection; `&str`
3150        // promoted through `.to_string()`). A regression that inserted
3151        // a normalization step at the primitive the pre-lift literal
3152        // does NOT apply — or vice versa — surfaces here rather than
3153        // as silent drift between the four consumers and the ONE
3154        // substrate owner they now route through.
3155        let owned_name = String::from("pr-42-demo");
3156        let owned_ns = String::from("ephemeral-pools");
3157        let lifted = AllocationRef::new(owned_name.clone(), owned_ns.clone());
3158        let pre_lift = AllocationRef {
3159            name: owned_name,
3160            namespace: owned_ns,
3161        };
3162        assert_eq!(lifted, pre_lift);
3163    }
3164
3165    #[test]
3166    fn allocation_ref_new_accepts_str_provenance_via_into_string() {
3167        // `Into<String>` provenance-closure pin: the primitive accepts
3168        // every provenance the pre-lift sites carried. The
3169        // controller_allocation.rs assignedProcess seeds passed owned
3170        // `String` values (a moved `member_process_name` +
3171        // `ns.clone()`); the allocation_factory.rs pool_ref seed
3172        // passed `&str` (`n.to_string()` / `namespace.to_string()`).
3173        // Both provenances produce byte-identical output. A future
3174        // refactor of the constructor signature that demanded owned
3175        // `String` at author sites (dropping `impl Into<String>`)
3176        // would force `.to_string()` back at the FOUR call sites — the
3177        // pin fences that regression at ONE place.
3178        let from_str = AllocationRef::new("pr-42-demo", "ephemeral-pools");
3179        let from_string =
3180            AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
3181        assert_eq!(from_str, from_string);
3182        // Mixed provenance is also load-bearing: the allocation_decide.rs
3183        // matched_pool seed pairs an owned `String` (from
3184        // `EphemeralPool::owned_name_or_empty()`) with a hand-authored
3185        // `.clone().unwrap_or_default()` — also `String`. The
3186        // controller_allocation.rs paths pair a moved `String` name
3187        // with a `.clone()`-ed `ns: String`. Verify (owned, borrow)
3188        // and (borrow, owned) both compose to the same shape as
3189        // (owned, owned) / (borrow, borrow).
3190        let mixed_a = AllocationRef::new(String::from("pr-42-demo"), "ephemeral-pools");
3191        let mixed_b = AllocationRef::new("pr-42-demo", String::from("ephemeral-pools"));
3192        assert_eq!(from_str, mixed_a);
3193        assert_eq!(from_str, mixed_b);
3194    }
3195
3196    #[test]
3197    fn allocation_ref_new_positional_axis_order_pinned_name_first_namespace_second() {
3198        // Axis-order pin: name is the FIRST positional argument;
3199        // namespace is the SECOND. Reversing the pair at the
3200        // constructor is the exact regression this pin fences — the
3201        // FOUR pre-lift sites all spelled `name` before `namespace`
3202        // (matching the struct definition's field order in
3203        // `pub struct AllocationRef { pub name, pub namespace }`)
3204        // and the wire-format serde output `{ "name": "...",
3205        // "namespace": "..." }` reflects that order. A slot swap at
3206        // the primitive would surface here rather than as silent
3207        // `<namespace>/<name>` inversion at every downstream
3208        // qualified-ref composer that reads `{ref.name}/{ref.namespace}`
3209        // as an audit-log key.
3210        let r = AllocationRef::new("alpha-name", "beta-namespace");
3211        assert_eq!(r.name, "alpha-name");
3212        assert_eq!(r.namespace, "beta-namespace");
3213        assert_ne!(r.name, "beta-namespace");
3214        assert_ne!(r.namespace, "alpha-name");
3215    }
3216
3217    #[test]
3218    fn allocation_ref_new_preserves_empty_string_verbatim() {
3219        // Empty-string sentinel pin: the constructor is pure — it does
3220        // NOT canonicalize empty inputs (does NOT default an empty
3221        // namespace to `"default"`; does NOT reject an empty name).
3222        // Preserves the pre-lift shape the allocation_decide.rs
3223        // matched_pool seed relied on: when the pool's metadata.namespace
3224        // is absent, `.clone().unwrap_or_default()` yields the empty
3225        // string, and the AllocationRef's namespace slot carries that
3226        // empty string verbatim to the downstream `bound_pool` sink.
3227        // A future canonicalization pass (e.g. defaulting to
3228        // `Process::DEFAULT_NAMESPACE`) MUST land here, not at the
3229        // primitive body silently, so the pre-lift consumers' empty-
3230        // sentinel semantics are the visible contract of the new
3231        // constructor.
3232        let r = AllocationRef::new("", "");
3233        assert_eq!(r.name, "");
3234        assert_eq!(r.namespace, "");
3235        let mixed = AllocationRef::new("pr-42-demo", "");
3236        assert_eq!(mixed.name, "pr-42-demo");
3237        assert_eq!(mixed.namespace, "");
3238    }
3239
3240    #[test]
3241    fn allocation_ref_new_composes_with_owned_name_or_empty_pool_projection() {
3242        // Composition pin: the constructor composes with the paired
3243        // substrate primitives [`EphemeralPool::owned_name_or_empty`]
3244        // + [`EphemeralPool::owned_namespace_or_empty`] at the
3245        // allocation_decide.rs pool_ref seed — the same primitive
3246        // family the pool CRD opened for both halves of the
3247        // `AllocationRef { name, namespace }` struct literal. The
3248        // composed pair carries an owned `String` name half (from
3249        // `pool.owned_name_or_empty()`) and an owned `String`
3250        // namespace half (from `pool.owned_namespace_or_empty()`) —
3251        // no pre-lift chain remains. A regression that broke the
3252        // primitive family's `impl Into<String>` acceptance of an
3253        // owned `String` return type would surface here rather than
3254        // as silent build failure at the pool-reconciler matched_pool
3255        // seed.
3256        let pool = pool_named("attest-pool");
3257        let r = AllocationRef::new(pool.owned_name_or_empty(), pool.owned_namespace_or_empty());
3258        assert_eq!(r.name, "attest-pool");
3259        assert_eq!(r.namespace, pool.owned_namespace_or_empty());
3260    }
3261
3262    #[test]
3263    fn allocation_ref_new_returns_wire_format_serialization_verbatim() {
3264        // Wire-format pin: the constructor produces an
3265        // [`AllocationRef`] whose serde `rename_all = "camelCase"`
3266        // serialization is byte-identical to the pre-lift struct
3267        // literal's serialization. The `bound_pool` and
3268        // `assignedProcess` slots on `AllocationStatus` (and the
3269        // `poolRef` slot on `AllocationSpec`) all round-trip through
3270        // this shape — the pin fences a regression that added a
3271        // private field or a `#[serde(skip)]` accidentally.
3272        let r = AllocationRef::new("pr-42-demo", "ephemeral-pools");
3273        let yaml = serde_yaml::to_string(&r).expect("AllocationRef serializes to yaml");
3274        assert!(yaml.contains("name: pr-42-demo"), "{yaml}");
3275        assert!(yaml.contains("namespace: ephemeral-pools"), "{yaml}");
3276        let back: AllocationRef =
3277            serde_yaml::from_str(&yaml).expect("AllocationRef round-trips through yaml");
3278        assert_eq!(back, r);
3279    }
3280
3281    fn member(state: MemberState) -> PoolMember {
3282        PoolMember {
3283            process_name: "m".into(),
3284            state,
3285            entered_state_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
3286            allocation_ref: None,
3287        }
3288    }
3289
3290    #[test]
3291    fn state_count_fanout_returns_all_zeros_on_empty_slice() {
3292        // Zero-length pin: the empty-members corner produces a
3293        // 4-tuple of zero counters, matching the pre-lift
3294        // `count_state` fanout's four `.iter().filter(...).count()`
3295        // calls each returning 0 on an empty iterator.
3296        assert_eq!(PoolMember::state_count_fanout(&[]), (0, 0, 0, 0));
3297    }
3298
3299    #[test]
3300    fn state_count_fanout_partitions_variants_into_correct_slots() {
3301        // Positional-axis pin: the returned 4-tuple's slot order
3302        // matches the four `PoolStatus` counter slots in declaration
3303        // order — `(ready, allocated, spawning, returning)`. A
3304        // regression that swapped two slots (e.g., `ready` ↔
3305        // `spawning`) surfaces here rather than as an operator-facing
3306        // scale-out oscillation at the pool reconciler.
3307        let members = vec![
3308            member(MemberState::Free),
3309            member(MemberState::Free),
3310            member(MemberState::Allocated),
3311            member(MemberState::Spawning),
3312            member(MemberState::Spawning),
3313            member(MemberState::Spawning),
3314            member(MemberState::Returning),
3315        ];
3316        assert_eq!(PoolMember::state_count_fanout(&members), (2, 1, 3, 1));
3317    }
3318
3319    #[test]
3320    fn state_count_fanout_excludes_failed_from_every_counter() {
3321        // Closed-set pin: no `PoolStatus` slot counts `Failed` members
3322        // (they surface via `PoolPhase::Degraded` instead of a status
3323        // counter). This test fences a regression that let a `Failed`
3324        // member drift into one of the four counters and inflate the
3325        // operator-visible ready/allocated/spawning/returning fanout.
3326        let members = vec![
3327            member(MemberState::Failed),
3328            member(MemberState::Failed),
3329            member(MemberState::Failed),
3330        ];
3331        assert_eq!(PoolMember::state_count_fanout(&members), (0, 0, 0, 0));
3332
3333        // Mixed with a Free member: the Free member is counted, the
3334        // Failed members are not.
3335        let mixed = vec![
3336            member(MemberState::Free),
3337            member(MemberState::Failed),
3338            member(MemberState::Failed),
3339        ];
3340        assert_eq!(PoolMember::state_count_fanout(&mixed), (1, 0, 0, 0));
3341    }
3342
3343    #[test]
3344    fn state_count_fanout_matches_pre_lift_count_state_helper_verbatim() {
3345        // Parity pin: for every possible members list, the 4-tuple
3346        // returned by the substrate primitive matches the pre-lift
3347        // `count_state(&members, MemberState::<slot>)` fanout that
3348        // pool-reconciler restated at both status-patch sites. The
3349        // pre-lift helper was
3350        // ```rust,ignore
3351        // fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
3352        //     members.iter().filter(|m| m.state == target).count() as u32
3353        // }
3354        // ```
3355        // — re-implemented inline here as an oracle.
3356        fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
3357            members.iter().filter(|m| m.state == target).count() as u32
3358        }
3359        let members = vec![
3360            member(MemberState::Free),
3361            member(MemberState::Allocated),
3362            member(MemberState::Allocated),
3363            member(MemberState::Spawning),
3364            member(MemberState::Returning),
3365            member(MemberState::Returning),
3366            member(MemberState::Failed),
3367        ];
3368        let (ready, allocated, spawning, returning) = PoolMember::state_count_fanout(&members);
3369        assert_eq!(ready, count_state(&members, MemberState::Free));
3370        assert_eq!(allocated, count_state(&members, MemberState::Allocated));
3371        assert_eq!(spawning, count_state(&members, MemberState::Spawning));
3372        assert_eq!(returning, count_state(&members, MemberState::Returning));
3373    }
3374
3375    // ─── PoolMember::process_names_set substrate pins ─────────────────
3376    //
3377    // Pins the closed-set slice-owned collection primitive on the
3378    // `process_name` axis into a `HashSet<String>` — the O(1)-lookup
3379    // shape both spawn arms in
3380    // `tatara-pool-reconciler::controller_pool` build pre-collision-
3381    // check against a candidate `member_process_name(&pool_name,
3382    // &pool_uid, slot)`. Sibling to `state_count_fanout` on the
3383    // `(collection shape × slice-owned fold)` axis; the fanout owns
3384    // the state-counter tuple corner, this primitive owns the
3385    // process-name-lookup corner. Fail-before-pass-after granularity:
3386    // `process_names_set` did not exist pre-lift; the compiler cannot
3387    // resolve the name until the impl block above is in place, so a
3388    // rollback of the primitive breaks this whole test group.
3389
3390    fn named_member(process_name: &str, state: MemberState) -> PoolMember {
3391        PoolMember {
3392            process_name: process_name.into(),
3393            state,
3394            entered_state_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
3395            allocation_ref: None,
3396        }
3397    }
3398
3399    #[test]
3400    fn process_names_set_returns_empty_hashset_on_empty_slice() {
3401        // Zero-length pin: the empty-members corner produces an
3402        // empty `HashSet<String>`, matching the pre-lift
3403        // `.iter().map(...).collect()` chain's empty-iterator
3404        // behavior. A regression that started producing a sentinel
3405        // entry (a `""` placeholder, a static seed) on the empty-
3406        // slice corner would silently reject the first spawn slot
3407        // downstream — the pin closes that failure mode.
3408        let empty: Vec<PoolMember> = vec![];
3409        assert!(PoolMember::process_names_set(&empty).is_empty());
3410    }
3411
3412    #[test]
3413    fn process_names_set_collects_every_process_name_from_populated_slice() {
3414        // Positive pin: every `PoolMember`'s `process_name` slot
3415        // lands in the returned `HashSet<String>` verbatim. Cross-
3416        // state (Free / Allocated / Spawning / Returning / Failed)
3417        // to prove the primitive is state-agnostic — the spawn arms
3418        // check occupancy on the name axis, NOT the state axis, so a
3419        // future refactor that filtered by state would silently
3420        // leave a returned/failed slot open to a duplicate spawn.
3421        let members = vec![
3422            named_member("pool-a-0", MemberState::Free),
3423            named_member("pool-a-1", MemberState::Allocated),
3424            named_member("pool-a-2", MemberState::Spawning),
3425            named_member("pool-a-3", MemberState::Returning),
3426            named_member("pool-a-4", MemberState::Failed),
3427        ];
3428        let set = PoolMember::process_names_set(&members);
3429        assert_eq!(set.len(), 5);
3430        for slot in 0..5 {
3431            let want = format!("pool-a-{slot}");
3432            assert!(set.contains(&want), "missing {want}; set = {set:?}");
3433        }
3434    }
3435
3436    #[test]
3437    fn process_names_set_deduplicates_duplicate_process_names() {
3438        // Deduplication pin: two `PoolMember` entries with the same
3439        // `process_name` (a race between the two spawn arms, an
3440        // adopted foreign Process the reconciler picked up twice)
3441        // collapse to ONE entry in the `HashSet<String>`. Pins the
3442        // `HashSet` deduplication semantics the pre-lift `.iter()
3443        // .map(...).collect()` chain already inherited from the
3444        // `FromIterator` impl — a regression that swapped the
3445        // aggregate to a `Vec<String>` or `BTreeSet<String>` still
3446        // matches the shape but changes the operator-visible count
3447        // at the `.len()` probe here.
3448        let members = vec![
3449            named_member("pool-b-0", MemberState::Free),
3450            named_member("pool-b-0", MemberState::Spawning),
3451            named_member("pool-b-1", MemberState::Free),
3452        ];
3453        let set = PoolMember::process_names_set(&members);
3454        assert_eq!(set.len(), 2);
3455        assert!(set.contains("pool-b-0"));
3456        assert!(set.contains("pool-b-1"));
3457    }
3458
3459    #[test]
3460    fn process_names_set_membership_probe_matches_pre_lift_chain_verbatim() {
3461        // Byte-identical parity pin: the `.contains(&candidate)`
3462        // probe on the substrate's `HashSet<String>` return returns
3463        // the same `bool` as the pre-lift `members.iter().map(|m|
3464        // m.process_name.clone()).collect::<HashSet<_>>().contains
3465        // (&candidate)` chain across the FULL cross product of
3466        // (candidate ∈ {an existing name, a novel name, the empty
3467        // string}). A regression that inserted a normalization step
3468        // at the primitive the pre-lift chain does NOT apply — or
3469        // vice versa — surfaces here rather than as silent drift
3470        // between the two spawn arms the primitive owns.
3471        let members = vec![
3472            named_member("pool-c-0", MemberState::Free),
3473            named_member("pool-c-1", MemberState::Allocated),
3474        ];
3475        let candidates: [&str; 4] = ["pool-c-0", "pool-c-1", "pool-c-2", ""];
3476        let via_primitive = PoolMember::process_names_set(&members);
3477        for candidate in candidates {
3478            let pre_lift: std::collections::HashSet<String> =
3479                members.iter().map(|m| m.process_name.clone()).collect();
3480            assert_eq!(
3481                via_primitive.contains(candidate),
3482                pre_lift.contains(candidate),
3483                "candidate = {candidate:?}"
3484            );
3485        }
3486    }
3487
3488    #[test]
3489    fn process_names_set_is_a_pure_projection() {
3490        // Consecutive calls on the same slice return equal sets —
3491        // no cached state, no mutation on the input. Guards against
3492        // a future refactor that plants a cache field somewhere and
3493        // drifts one caller from another silently.
3494        let members = vec![
3495            named_member("pool-d-0", MemberState::Free),
3496            named_member("pool-d-1", MemberState::Spawning),
3497        ];
3498        let first = PoolMember::process_names_set(&members);
3499        let second = PoolMember::process_names_set(&members);
3500        assert_eq!(first, second);
3501    }
3502
3503    #[test]
3504    fn pool_status_observed_composes_pre_lift_status_seed_verbatim() {
3505        // Composition pin: the substrate constructor produces a
3506        // `PoolStatus` structurally equal to the pre-lift 11-line
3507        // struct literal both pool-reconciler status-patch sites
3508        // stamped by hand. Any drift in the defaults (`message`,
3509        // `conditions`) or in the counter fanout surfaces here.
3510        let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
3511        let members = vec![
3512            member(MemberState::Free),
3513            member(MemberState::Allocated),
3514            member(MemberState::Spawning),
3515            member(MemberState::Returning),
3516            member(MemberState::Failed),
3517        ];
3518        let member_count = members.len();
3519        let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
3520        assert_eq!(observed.phase, PoolPhase::Steady);
3521        assert_eq!(observed.phase_since, Some(now));
3522        assert_eq!(observed.ready_count, 1);
3523        assert_eq!(observed.allocated_count, 1);
3524        assert_eq!(observed.spawning_count, 1);
3525        assert_eq!(observed.returning_count, 1);
3526        assert_eq!(observed.members.len(), member_count);
3527        assert!(observed.message.is_none());
3528        assert!(observed.conditions.is_empty());
3529    }
3530
3531    #[test]
3532    fn pool_status_observed_moves_members_by_value_without_extra_clone() {
3533        // Ownership pin: the constructor consumes the members Vec by
3534        // value rather than borrowing + cloning internally. Both pre-
3535        // lift sites called `.clone()` on their `members` binding for
3536        // the struct-literal `members:` slot; the substrate lift keeps
3537        // the same one-clone bound at the caller (or a straight move
3538        // if the caller no longer needs the local `members` binding
3539        // after the seed) rather than accidentally cloning twice.
3540        let members = vec![member(MemberState::Free), member(MemberState::Spawning)];
3541        let now = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
3542        let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
3543        assert_eq!(observed.members.len(), 2);
3544    }
3545
3546    // ─── EphemeralPool::has_name substrate pins ───────────────────────
3547    //
3548    // Pins the copy-form metadata-projection primitive on the
3549    // `metadata.name` axis's presence-and-equal corner — the
3550    // discriminant every `candidate_pools.iter().find(|p| ...)`
3551    // closure that resolves a pool from an owned-name handle
3552    // (`AllocationRef.name` / `AllocationDecision::Bind.pool.name`)
3553    // routes through. Sibling to the `_or_empty` family on the SAME
3554    // slot ([`EphemeralPool::name_or_empty`] +
3555    // [`EphemeralPool::owned_name_or_empty`]) — this primitive owns
3556    // the `None`-preserving corner the `_or_empty` family folds away.
3557    // Fail-before-pass-after granularity: `has_name` did not exist
3558    // pre-lift; the compiler cannot resolve the name until the impl
3559    // block above is in place, so a rollback of the primitive breaks
3560    // this whole module.
3561    #[test]
3562    fn has_name_returns_true_when_slot_is_populated_and_equal() {
3563        // Happy-path pin: the slot is set AND byte-identical to the
3564        // candidate. Both pre-lift `find` closures — `resolve_pool`'s
3565        // explicit-`pool_ref` half and `controller_allocation`'s TTL-
3566        // inheritance fallback — resolve their target pool exactly in
3567        // this corner, and the primitive returns `true` here to
3568        // authorize the resolution.
3569        let p = pool_named("attest-pool");
3570        assert!(p.has_name("attest-pool"));
3571    }
3572
3573    #[test]
3574    fn has_name_returns_false_when_slot_is_populated_and_different() {
3575        // Populated-slot inequality pin: the primitive returns `false`
3576        // for every candidate that is NOT byte-identical to the slot,
3577        // including strict subsequences (`"attest"` vs. `"attest-pool"`),
3578        // strict superstrings (`"attest-pool-2"` vs. `"attest-pool"`),
3579        // and case-differ variants. This is the load-bearing property
3580        // that lets `find(|p| p.has_name(&candidate))` reject
3581        // non-matching pools rather than aliasing them together.
3582        let p = pool_named("attest-pool");
3583        assert!(!p.has_name("other-pool"));
3584        assert!(!p.has_name("attest"));
3585        assert!(!p.has_name("attest-pool-2"));
3586        assert!(!p.has_name("ATTEST-POOL"));
3587    }
3588
3589    #[test]
3590    fn has_name_returns_false_when_slot_is_none_even_against_empty_candidate() {
3591        // The `None`-preserving discipline pin: an unset `metadata.name`
3592        // slot returns `false` even when the candidate is the empty
3593        // string. Distinguishes `has_name` from a naïve substitution
3594        // through the sibling `name_or_empty` primitive, which would
3595        // fold both `None` and `Some("")` to `""` and silently promote
3596        // an unnamed pool with an empty candidate into a spurious
3597        // match at the resolver's `find` closure. Byte-identical to
3598        // what the pre-lift `.as_deref() == Some(<candidate>)` chain
3599        // produced (`None == Some("")` is `false`), which is what
3600        // both consumer sites relied on.
3601        let p = pool_unnamed();
3602        assert!(p.metadata.name.is_none(), "fixture invariant");
3603        assert!(!p.has_name(""));
3604        assert!(!p.has_name("attest-pool"));
3605    }
3606
3607    #[test]
3608    fn has_name_returns_true_only_when_populated_slot_and_candidate_are_both_empty() {
3609        // Populated-empty-slot corner pin: `Some(String::new())` is a
3610        // populated slot with an empty payload. `has_name("")` returns
3611        // `true` here (byte-identical `""` on both sides), while
3612        // `has_name("<anything else>")` returns `false`. This is the
3613        // corner where `has_name` DIVERGES from `name_or_empty`
3614        // observably: the `_or_empty` family folds this corner into
3615        // the same bucket as `None`, but `has_name` keeps the
3616        // presence bit visible — `Some("") == Some("")` is `true`
3617        // while `None == Some("")` is `false`.
3618        let mut p = pool_named("scratch");
3619        p.metadata.name = Some(String::new());
3620        assert!(p.has_name(""));
3621        assert!(!p.has_name("attest-pool"));
3622    }
3623
3624    #[test]
3625    fn has_name_matches_pre_lift_chain_verbatim_across_full_corner_set() {
3626        // Byte-identical parity pin: the primitive returns the same
3627        // `bool` as the pre-lift `.metadata.name.as_deref() == Some
3628        // (candidate)` chain across the FULL cross product of
3629        // (slot ∈ {None, Some("attest-pool"), Some("")}) × (candidate
3630        // ∈ {"attest-pool", "", "other"}). A regression that inserted
3631        // a normalization step at the primitive the pre-lift chain
3632        // does NOT apply — or vice versa — surfaces here rather than
3633        // as silent drift between the two `find` closures the primitive
3634        // owns.
3635        let slots: [Option<String>; 3] =
3636            [None, Some(String::from("attest-pool")), Some(String::new())];
3637        let candidates: [&str; 3] = ["attest-pool", "", "other"];
3638        for slot in slots {
3639            let mut p = pool_named("scratch");
3640            p.metadata.name = slot.clone();
3641            for candidate in candidates {
3642                let pre_lift = p.metadata.name.as_deref() == Some(candidate);
3643                assert_eq!(
3644                    p.has_name(candidate),
3645                    pre_lift,
3646                    "slot = {slot:?}, candidate = {candidate:?}"
3647                );
3648            }
3649        }
3650    }
3651
3652    #[test]
3653    fn has_name_diverges_from_name_or_empty_on_the_missing_slot_corner() {
3654        // Cross-primitive discipline pin: `has_name("")` and
3655        // `name_or_empty() == ""` MUST disagree on the `None`-slot
3656        // corner. `name_or_empty` returns `""` (its load-bearing
3657        // sentinel), so a naïve `name_or_empty() == ""` probe would
3658        // return `true` here — aliasing every unnamed pool to the
3659        // empty-candidate bucket at the resolver. `has_name`
3660        // preserves `Option::as_deref() == Some(_)`'s `None ⇒ false`
3661        // semantics, so it returns `false` and rejects the spurious
3662        // match. This test fences the WHOLE reason `has_name` exists
3663        // as a distinct primitive from the `_or_empty` family: a
3664        // future refactor that collapsed `has_name` into
3665        // `name_or_empty() == candidate` would break this pin and
3666        // silently regress the resolver's byte-comparison honesty.
3667        let p = pool_unnamed();
3668        assert_eq!(p.name_or_empty(), "");
3669        assert!(!p.has_name(""));
3670    }
3671
3672    #[test]
3673    fn has_name_is_a_pure_projection() {
3674        // Consecutive calls with the same candidate return the same
3675        // `bool` — no cached state, no mutation on the `EphemeralPool`
3676        // between calls. Guards against a future refactor that plants
3677        // a cache field on `EphemeralPool` and drifts one caller from
3678        // another silently.
3679        let p = pool_named("router-pool");
3680        assert_eq!(p.has_name("router-pool"), p.has_name("router-pool"));
3681        assert_eq!(p.has_name("other"), p.has_name("other"));
3682        assert!(p.has_name("router-pool"));
3683        assert!(!p.has_name("other"));
3684    }
3685
3686    // ─── PoolSpec::free_ttl_duration substrate pins ─────────────────
3687    //
3688    // The `humantime::parse_duration(&<field>).ok()` shape rides
3689    // through TWO peer inherent methods on peer spec types post-lift:
3690    // [`crate::lifetime::EphemeralLifetime::ttl_duration`] on the
3691    // `spec.lifetime.ephemeral.ttl` axis + [`PoolSpec::free_ttl_
3692    // duration`] on the `pool.spec.free_ttl` axis. These pins bind the
3693    // pool-spec-side primitive at fail-before-pass-after granularity
3694    // so a regression that drifts either surface (a per-fleet minimum
3695    // floor added at only one primitive, a canonical unit-normalization
3696    // pass, a warn-log on unparseable strings) fails here rather than
3697    // as silent operator-facing skew between the pool stale-free
3698    // bucket loop in `tatara-pool-reconciler::pool_decide::decide_pool`
3699    // and the ephemeral TTL-expiry gate in
3700    // `tatara-process::lifetime_clock::evaluate`.
3701
3702    fn pool_spec_with_free_ttl(free_ttl: &str) -> PoolSpec {
3703        PoolSpec {
3704            free_ttl: free_ttl.into(),
3705            ..pool_spec()
3706        }
3707    }
3708
3709    #[test]
3710    fn pool_spec_free_ttl_duration_parseable_humantime_projects_to_some() {
3711        for (ttl, expected_secs) in [
3712            ("30s", 30u64),
3713            ("5m", 300),
3714            ("1h", 3600),
3715            ("24h", 86_400),
3716            ("1d", 86_400),
3717        ] {
3718            let spec = pool_spec_with_free_ttl(ttl);
3719            assert_eq!(
3720                spec.free_ttl_duration(),
3721                Some(std::time::Duration::from_secs(expected_secs)),
3722                "free_ttl_duration drift for {ttl:?}",
3723            );
3724        }
3725    }
3726
3727    #[test]
3728    fn pool_spec_free_ttl_duration_unparseable_returns_none() {
3729        // A typo (`"1our"`), an unsupported unit (`"1w"` — humantime
3730        // supports `w`, but `"forever"` doesn't), a non-humantime
3731        // literal that reached the field via API-server acceptance
3732        // ALL collapse to `None`. The `pool_decide::decide_pool`
3733        // caller collapses the corner via `.unwrap_or_default()`,
3734        // yielding `Duration::ZERO` — byte-identical to the pre-lift
3735        // hand-authored `humantime::parse_duration(&spec.free_ttl)
3736        // .unwrap_or_default()` semantics.
3737        for bad in ["", "1our", "forever", "not-a-duration", "1", "-1s"] {
3738            let spec = pool_spec_with_free_ttl(bad);
3739            assert_eq!(
3740                spec.free_ttl_duration(),
3741                None,
3742                "free_ttl_duration should be None for {bad:?}",
3743            );
3744        }
3745    }
3746
3747    #[test]
3748    fn pool_spec_free_ttl_duration_zero_seconds_returns_some_zero() {
3749        // `"0s"` is a parseable-but-zero humantime literal — the
3750        // primitive returns `Some(Duration::ZERO)`, distinguishable
3751        // from the parse-failure `None` corner. Downstream consumers
3752        // that gate on `!free_ttl.is_zero()` collapse this back
3753        // together with the `None`-via-`unwrap_or_default()` corner,
3754        // but the primitive itself keeps the two shapes distinct so
3755        // a future consumer needing that distinction can reach for
3756        // it without a re-parse.
3757        let spec = pool_spec_with_free_ttl("0s");
3758        assert_eq!(
3759            spec.free_ttl_duration(),
3760            Some(std::time::Duration::ZERO),
3761            "0s should project to Some(Duration::ZERO), not None",
3762        );
3763    }
3764
3765    #[test]
3766    fn pool_spec_free_ttl_duration_default_free_ttl_matches_24h() {
3767        // The default `free_ttl` is `"24h"` (via [`default_free_ttl`]).
3768        // The primitive on a `PoolSpec` carrying the default must
3769        // agree with a manually-parsed `"24h"` — a future
3770        // `default_free_ttl` change (a shorter recycling window, a
3771        // per-fleet override) reaches BOTH surfaces at once (this
3772        // pin + the `default_free_ttl` fn) without silent skew.
3773        let spec = pool_spec_with_free_ttl(&default_free_ttl());
3774        assert_eq!(
3775            spec.free_ttl_duration(),
3776            Some(std::time::Duration::from_secs(24 * 3600)),
3777        );
3778    }
3779
3780    #[test]
3781    fn pool_spec_free_ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
3782        // Byte-shape parity with the pre-lift hand-authored chain the
3783        // `pool_decide::decide_pool` stale-free bucket loop restated
3784        // (`humantime::parse_duration(&spec.free_ttl).ok()` — the
3785        // `.ok()` tail and the caller's `.unwrap_or_default()` compose
3786        // to the same `Duration::ZERO`-on-failure semantics). Sweeps
3787        // every callsite corner the pool reconciler plausibly
3788        // encounters: the default `"24h"` free-recycling window, a
3789        // short-window test override (`"10s"`), a parse-failure typo,
3790        // an empty string.
3791        for ttl in ["24h", "10s", "1our", ""] {
3792            let spec = pool_spec_with_free_ttl(ttl);
3793            let via_primitive = spec.free_ttl_duration();
3794            let hand_authored = humantime::parse_duration(&spec.free_ttl).ok();
3795            assert_eq!(
3796                via_primitive, hand_authored,
3797                "free_ttl_duration must be byte-identical to `humantime::\
3798                 parse_duration(&spec.free_ttl).ok()` for {ttl:?}",
3799            );
3800        }
3801    }
3802
3803    #[test]
3804    fn pool_spec_free_ttl_duration_matches_peer_ephemeral_lifetime_ttl_duration_shape() {
3805        // Return-shape parity with the peer primitive
3806        // [`crate::lifetime::EphemeralLifetime::ttl_duration`]: given
3807        // the SAME humantime string on both peer fields (the pool
3808        // `free_ttl` slot AND the ephemeral `ttl` slot), the two
3809        // primitives return byte-identical `Option<Duration>` values.
3810        // A regression that inserted a per-primitive normalization
3811        // step at only one surface — a per-fleet minimum floor, a
3812        // canonical unit-normalization pass — surfaces here rather
3813        // than as silent operator-facing skew between the pool
3814        // stale-free bucket loop and the ephemeral TTL-expiry gate
3815        // on the SAME humantime literal.
3816        for ttl in ["30s", "1h", "24h", "1our", ""] {
3817            let pool_spec = pool_spec_with_free_ttl(ttl);
3818            let eph = crate::lifetime::EphemeralLifetime {
3819                ttl: ttl.into(),
3820                ..Default::default()
3821            };
3822            assert_eq!(
3823                pool_spec.free_ttl_duration(),
3824                eph.ttl_duration(),
3825                "peer-primitive shape drift for {ttl:?}",
3826            );
3827        }
3828    }
3829}