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