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 EphemeralPool {
148    /// Borrow-form metadata-projection primitive on the `metadata.name`
149    /// axis of `EphemeralPool`: returns the K8s object name slice with
150    /// the missing-name corner collapsed to the load-bearing empty-string
151    /// sentinel — the ONE-liner collapse of the paired
152    /// `self.metadata.name.as_deref().unwrap_or("")` incantation every
153    /// pool-side consumer restated by hand pre-lift.
154    ///
155    /// Pre-lift the `.metadata.name.as_deref().unwrap_or("")` chain
156    /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
157    /// duplication threshold in `tatara-pool-reconciler`, both keyed
158    /// by the pool's own name slot:
159    /// * `router::pool_name` — the tie-break comparator inside
160    ///   `best_match`; a deterministic lexicographic-min-name arbiter
161    ///   across two pool candidates whose specificity scores tie.
162    /// * `controller_allocation::reconcile_inner` — the `HashMap<
163    ///   pool-name, Vec<PoolMember>>` lookup closure fed into
164    ///   `decide_allocation_reconcile`; keys the "which pool members
165    ///   back this allocation candidate?" projection at every
166    ///   allocation-reconcile pass.
167    ///
168    /// Both sites walked the SAME `.as_deref().unwrap_or("")` chain
169    /// and both wanted the `&str` form the primitive returns — as a
170    /// borrow suitable for lexicographic `str::cmp` in the tie-break
171    /// AND for the `HashMap<String, _>::get(&str)` lookup. Post-lift
172    /// each caller reaches for `pool.name_or_empty()` and the produced
173    /// slice feeds the same downstream comparator / lookup unchanged.
174    ///
175    /// The empty-string fallback is the SAME sentinel the sibling
176    /// borrow-form primitive [`crate::crd::Process::uid_or_empty`]
177    /// returns AND the SAME sentinel the owned-form sibling
178    /// [`crate::crd::Process::owned_name_or_empty`] returns on the
179    /// `metadata.name` axis of the sister CRD — the three primitives
180    /// partition the (borrow-form × owned-form) × (uid × name) corner
181    /// of the metadata-slot family on identical fallback semantics
182    /// (empty string means "the slot is unset"), so a consumer that
183    /// switches between the CRD surfaces based on downstream keying
184    /// requirements never sees a different missing-slot spelling as
185    /// a side effect.
186    ///
187    /// Return-form axis: `&str` mirrors the borrow-first discipline
188    /// of the peer metadata primitives on `Process`
189    /// ([`crate::crd::Process::namespace_or_default`],
190    /// [`crate::crd::Process::name_or_placeholder`],
191    /// [`crate::crd::Process::uid_or_empty`]). The one missing-slot
192    /// corner the chain swallowed pre-lift (missing `metadata.name`)
193    /// collapses to the empty-string sentinel so `str::is_empty` /
194    /// `HashMap::get` on an unnamed pool behaves identically to what
195    /// the pre-lift `.as_deref().unwrap_or("")` chain produced.
196    ///
197    /// A future normalization step (a name-canonicalization pass, a
198    /// case-fold key builder, a per-cluster prefix stripper for
199    /// cross-cluster pool-name aliasing) lands at ONE substrate
200    /// method here and both downstream consumers pick up the upgrade
201    /// mechanically — no per-callsite hand-edit at `pool_name` /
202    /// `reconcile_inner`.
203    ///
204    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
205    /// the `.metadata.name.as_deref().unwrap_or("")` chain recurred
206    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
207    /// duplication trigger, and is lifted to ONE owner here).
208    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
209    /// the pins bind the missing-name corner + the empty-string
210    /// sentinel byte-shape + the borrow-form `&str` lifetime + the
211    /// byte-identical parity with the pre-lift chain + the fallback-
212    /// value coherence with `Process::uid_or_empty` /
213    /// `Process::owned_name_or_empty` on the metadata-slot × empty-
214    /// sentinel axis, so a regression that drifted any surface at
215    /// `tests::name_or_empty_*` here rather than as silent operator-
216    /// facing skew between the router tie-break and the allocation
217    /// member-lookup on the SAME pool candidate).
218    pub fn name_or_empty(&self) -> &str {
219        self.metadata.name.as_deref().unwrap_or("")
220    }
221
222    /// Owned-form metadata-projection primitive on the `metadata.name`
223    /// axis of `EphemeralPool`: returns an owned `String` copy of the K8s
224    /// object name with the missing-name corner collapsed to the load-
225    /// bearing empty-string sentinel — the ONE-liner collapse of the
226    /// paired `self.metadata.name.clone().unwrap_or_default()` incantation
227    /// every pool-side consumer restated by hand pre-lift.
228    ///
229    /// Pre-lift the `.metadata.name.clone().unwrap_or_default()` chain
230    /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
231    /// duplication threshold in `tatara-pool-reconciler`, both keyed by
232    /// the pool's own name slot in an `owned String` context:
233    /// * `controller_allocation::reconcile_inner` — the
234    ///   `HashMap<String, Vec<PoolMember>>` key seed inside a
235    ///   `pools.iter().map(|p| ...).collect()` fanout; the map key is
236    ///   the owned `String` form because the produced `HashMap<String, _>`
237    ///   outlives the pool-list borrow that generated it and the
238    ///   downstream `pool_members.get(pool.name_or_empty())` closure
239    ///   consumes it as `&str`.
240    /// * `allocation_decide::AllocationConvergenceCtx::observe` — the
241    ///   `AllocationRef::name` slot seed stamped on the matched-pool
242    ///   handle; the struct literal is `AllocationRef { name: String,
243    ///   namespace: String }` and the produced value is threaded through
244    ///   the `Decision::decide` transition rule downstream.
245    ///
246    /// Both sites walked the SAME `.clone().unwrap_or_default()` chain
247    /// and both wanted the `String` form the primitive returns — as the
248    /// owned key of a `HashMap<String, _>` and as the `String` slot of
249    /// an `AllocationRef` struct literal. Post-lift each callsite reads
250    /// `pool.owned_name_or_empty()` and the produced value feeds the
251    /// same downstream key / struct-literal slot unchanged.
252    ///
253    /// The empty-string fallback is the SAME sentinel the sibling
254    /// borrow-form primitive [`Self::name_or_empty`] returns AND the
255    /// SAME sentinel the sibling owned-form primitive
256    /// [`crate::crd::Process::owned_name_or_empty`] returns on the
257    /// `metadata.name` axis of the sister CRD — the three primitives
258    /// partition the (borrow-form × owned-form) corner of the metadata-
259    /// name family across BOTH tatara-process CRDs on identical missing-
260    /// slot semantics (empty string means "the slot is unset"), so a
261    /// consumer that switches between the CRD surfaces based on
262    /// downstream ownership requirements never sees a different
263    /// missing-slot spelling as a side effect.
264    ///
265    /// Peer to [`Self::name_or_empty`] on the (return-form × ownership)
266    /// axis pair — closes the corner the pool-side family previously
267    /// left open:
268    ///
269    /// * borrow + empty sentinel → [`Self::name_or_empty`] (router tie-
270    ///   break comparator, `HashMap<String, _>::get(&str)` lookup —
271    ///   consumers whose downstream keys by `&str` and allocates
272    ///   nothing);
273    /// * owned + empty sentinel → **this method** (HashMap-key seed in
274    ///   an outliving-borrow context, `AllocationRef::name` struct-
275    ///   literal slot — consumers whose downstream requires the owned
276    ///   `String` form because the produced value outlives the source-
277    ///   pool borrow).
278    ///
279    /// A future normalization step (a name-canonicalization pass, a
280    /// case-fold key builder, a per-cluster prefix stripper for cross-
281    /// cluster pool-name aliasing) lands at ONE substrate method here
282    /// and both downstream consumers pick up the upgrade mechanically —
283    /// no per-callsite hand-edit at `reconcile_inner` /
284    /// `AllocationConvergenceCtx::observe`.
285    ///
286    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
287    /// the `.metadata.name.clone().unwrap_or_default()` chain recurred
288    /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
289    /// duplication trigger, and is lifted to ONE owner here).
290    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
291    /// the pins bind the missing-name corner + the empty-string
292    /// sentinel byte-shape + the owned-form `String` return type + the
293    /// byte-identical parity with the pre-lift chain + the fallback-
294    /// value coherence with [`Self::name_or_empty`] +
295    /// [`crate::crd::Process::owned_name_or_empty`] on the metadata-
296    /// slot × empty-sentinel axis, so a regression that drifted any
297    /// surface at `tests::owned_name_or_empty_*` here rather than as
298    /// silent operator-facing skew between the pool-members lookup key
299    /// and the AllocationRef seed on the SAME pool candidate).
300    pub fn owned_name_or_empty(&self) -> String {
301        self.metadata.name.clone().unwrap_or_default()
302    }
303
304    /// Copy-form metadata-projection primitive on the deletion-tombstone
305    /// axis of `EphemeralPool`: returns `true` iff the K8s API server
306    /// has stamped a `metadata.deletionTimestamp` on this pool (the
307    /// moment the object entered the "being deleted" corner of its
308    /// lifecycle, after which further mutating writes are refused and
309    /// finalizers are drained before the object is actually removed) —
310    /// the ONE-liner collapse of the paired
311    /// `self.metadata.deletion_timestamp.is_some()` incantation every
312    /// pool-side consumer restated by hand pre-lift.
313    ///
314    /// Pre-lift the `.metadata.deletion_timestamp.is_some()` chain was
315    /// hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
316    /// duplication threshold in `tatara-pool-reconciler`, both
317    /// projecting the SAME tombstone-presence predicate on an
318    /// `EphemeralPool` value:
319    /// * `pool_decide::decide_pool_reconcile` — the pure decision
320    ///   function's deletion-preempt gate that forces
321    ///   [`PoolDecision::Drain`] as soon as the API server stamps
322    ///   the tombstone, before the (desired vs actual) supply-arithmetic
323    ///   branches get a chance to run. Wired at the very top of the
324    ///   decision so a draining pool never spawns / reaps / expires
325    ///   through the normal replenishment arithmetic while the
326    ///   deletion is in flight.
327    /// * `controller_pool::pool_phase_from_members` — the observed-
328    ///   phase composer's tombstone-first arm that returns
329    ///   [`PoolPhase::Draining`] regardless of the supply / demand
330    ///   arithmetic that would otherwise pick `Ready` / `Scaling` /
331    ///   `Degraded`. Keeps the reported phase honest during the
332    ///   finalizer drain so operators reading `kubectl get
333    ///   ephemeralpools` see the tombstone-present state as
334    ///   `Draining`, not as a stale `Ready`.
335    ///
336    /// Both sites walked the SAME `.metadata.deletion_timestamp
337    /// .is_some()` chain and both wanted the `bool` form the primitive
338    /// returns — the `decide_pool_reconcile` site to gate the
339    /// `→ Drain` short-circuit and the `pool_phase_from_members` site
340    /// to gate the `→ Draining` short-circuit. Post-lift each callsite
341    /// reads `pool.is_being_deleted()` and the produced `bool` feeds
342    /// the same downstream short-circuit unchanged.
343    ///
344    /// Sibling to [`crate::crd::Process::is_being_deleted`] on the
345    /// deletion-tombstone axis of the sister CRD — the two primitives
346    /// now partition the tombstone-presence probe across BOTH
347    /// tatara-process CRDs on identical missing-slot semantics
348    /// (present timestamp means "the API server has begun deletion"),
349    /// so an operator or reconciler that switches between the CRD
350    /// surfaces never sees a different tombstone-detection spelling
351    /// as a side effect.
352    ///
353    /// Return-form axis: `bool` matches the copy-form discipline of
354    /// the sibling [`crate::crd::Process::is_being_deleted`] and of
355    /// the pool-side [`crate::phase::ProcessPhase::is_alive`] +
356    /// [`Self::name_or_empty`]-family primitives — the underlying
357    /// slot is a wire-format `Option<Time>` that carries only
358    /// presence information at this axis (the RFC-3339 timestamp
359    /// payload itself is not what the two consumers read; both only
360    /// probe presence to detect the tombstone-stamped state).
361    /// Returning the raw `Option<&Time>` would push the `.is_some()`
362    /// probe back to every callsite, restating the pre-lift chain
363    /// one link shorter without collapsing the primitive.
364    ///
365    /// Peer to [`Self::name_or_empty`] and [`Self::owned_name_or_empty`]
366    /// on the metadata-projection axis for `EphemeralPool`; this method
367    /// opens the presence-probe corner for the tombstone slot. Future
368    /// metadata-presence projections on the pool CRD (an
369    /// `is_being_finalized` projection on
370    /// `metadata.finalizers.is_empty()`'s negation, a `has_owner`
371    /// projection on `metadata.owner_references.is_empty()`'s
372    /// negation) land as peer methods on this same axis.
373    ///
374    /// A future normalization step (a per-tombstone staleness gate
375    /// that returns `false` for a tombstone older than the reconciler's
376    /// grace-period budget, a canonicalization pass that treats a
377    /// tombstone from a paused controller as absent, a cross-cluster
378    /// tombstone-observation clock skew guard) lands at ONE substrate
379    /// method here and both downstream consumers pick up the upgrade
380    /// mechanically — no per-callsite hand-edit at
381    /// `decide_pool_reconcile` / `pool_phase_from_members`.
382    ///
383    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
384    /// the `.metadata.deletion_timestamp.is_some()` chain recurred at
385    /// two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
386    /// duplication trigger, and is lifted to ONE owner here).
387    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
388    /// the pins bind the missing-tombstone corner + the present-
389    /// tombstone corner + the copy-form `bool` return + the byte-
390    /// identical parity with the pre-lift `.is_some()` chain + the
391    /// cross-CRD coherence with `crate::crd::Process::is_being_deleted`
392    /// on the tombstone axis, so a regression that drifted any surface
393    /// at `tests::is_being_deleted_*` rather than as silent operator-
394    /// facing skew between the pool-reconciler's `→ Drain` decision
395    /// and the observed-phase composer's `→ Draining` report on the
396    /// SAME `EphemeralPool` within one reconcile pass).
397    pub fn is_being_deleted(&self) -> bool {
398        self.metadata.deletion_timestamp.is_some()
399    }
400
401    /// Owned-form metadata-projection primitive on the `metadata.namespace`
402    /// axis of `EphemeralPool`: returns an owned `String` copy of the K8s
403    /// namespace with the missing-namespace corner collapsed to the load-
404    /// bearing empty-string sentinel — the ONE-liner collapse of the
405    /// paired `self.metadata.namespace.clone().unwrap_or_default()`
406    /// incantation every pool-side consumer restated by hand pre-lift.
407    ///
408    /// Pre-lift the `.metadata.namespace.clone().unwrap_or_default()`
409    /// chain was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE
410    /// ≥ 2 duplication threshold, both stamping the `AllocationRef
411    /// { namespace: String, .. }` slot inside an owned-`String` context:
412    /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx
413    ///   ::observe` — the matched-pool seed's `AllocationRef.namespace`
414    ///   slot, right beside the peer [`Self::owned_name_or_empty`] call
415    ///   that owns the paired name half. This is the exact site the
416    ///   pre-existing peer-primitive doc-comment forecast (`"a future
417    ///   run may lift owned_namespace_or_empty as the sibling axis
418    ///   peer"`).
419    /// * `crate::pool::tests::allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`
420    ///   — the composition pin that seeded an `AllocationRef` from the
421    ///   same paired-primitive-half construction the production consumer
422    ///   in `allocation_decide::observe` performs. Post-lift the pin
423    ///   composes two peer primitives (`owned_name_or_empty` +
424    ///   `owned_namespace_or_empty`) rather than one primitive plus the
425    ///   pre-lift chain, sharpening it from a mixed-form composition
426    ///   check into a paired-primitive-family composition check.
427    ///
428    /// Both sites walked the SAME `.clone().unwrap_or_default()` chain
429    /// and both wanted the `String` form the primitive returns — as the
430    /// `String` slot of an `AllocationRef` struct literal built through
431    /// [`crate::pool::AllocationRef::new`]. Post-lift each callsite reads
432    /// `pool.owned_namespace_or_empty()` and the produced value feeds
433    /// the same downstream `AllocationRef` slot unchanged.
434    ///
435    /// The empty-string fallback is the SAME sentinel the sibling owned-
436    /// form primitive [`Self::owned_name_or_empty`] returns on the
437    /// `metadata.name` axis of the same CRD — the two primitives now
438    /// partition the (owned `String` × `metadata.<slot>`) corner of the
439    /// pool CRD's metadata family across BOTH object-coordinate slots
440    /// on identical missing-slot semantics (empty string means "the
441    /// slot is unset"), so the [`crate::pool::AllocationRef::new`]
442    /// composer sees a coherent owned-empty pair regardless of which
443    /// slot is absent on the source pool. Coherent with the workspace-
444    /// wide owned-empty sentinel that the peer primitives
445    /// [`crate::crd::Process::uid_or_empty`],
446    /// [`crate::crd::Process::owned_name_or_empty`],
447    /// [`Self::name_or_empty`], and [`Self::owned_name_or_empty`]
448    /// already share on the metadata-slot × empty-sentinel axis.
449    ///
450    /// Peer to [`Self::owned_name_or_empty`] on the
451    /// (`metadata.name` × `metadata.namespace`) axis of the owned-form
452    /// projection family — closes the corner the pool-side family
453    /// previously left open:
454    ///
455    /// * owned + name + empty sentinel → [`Self::owned_name_or_empty`]
456    ///   (`AllocationRef.name` seed, `HashMap<String, _>` key seed);
457    /// * owned + namespace + empty sentinel → **this method**
458    ///   (`AllocationRef.namespace` seed — the paired half the same
459    ///   `AllocationRef::new(name, namespace)` constructor consumes);
460    /// * copy + deletion + tombstone probe → [`Self::is_being_deleted`]
461    ///   (the presence-probe corner of the same metadata axis, already
462    ///   opened).
463    ///
464    /// A future normalization step (a namespace-canonicalization pass,
465    /// a case-fold key builder, a per-cluster prefix stripper, or the
466    /// canonical-namespace default lift that would substitute
467    /// [`crate::crd::Process::DEFAULT_NAMESPACE`] on the missing-slot
468    /// corner rather than the empty-string sentinel) lands at ONE
469    /// substrate method here and both downstream consumers pick up the
470    /// upgrade mechanically — no per-callsite hand-edit at
471    /// `AllocationConvergenceCtx::observe` / the composition pin.
472    ///
473    /// The empty-string fallback (rather than
474    /// [`crate::crd::Process::DEFAULT_NAMESPACE`]) is DELIBERATELY
475    /// pinned: the sole downstream consumer
476    /// (`AllocationConvergenceCtx::observe`'s matched-pool seed) feeds
477    /// the produced value into `AllocationRef.namespace`, which is then
478    /// matched byte-identically against `spec.pool_ref.namespace` at
479    /// [`crate::pool::allocation_decide::resolve_pool`]-style comparators.
480    /// A silent substitution of `"default"` at this primitive would
481    /// alias every namespace-absent pool to the `"default"` bucket at
482    /// the matcher, hiding the missing-slot corner from an operator
483    /// who explicitly authored an allocation against a namespace-
484    /// unset pool. The load-bearing empty-string sentinel keeps the
485    /// pre-lift `.clone().unwrap_or_default()` shape verbatim so the
486    /// downstream matcher's byte-comparison stays honest.
487    ///
488    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
489    /// the `.metadata.namespace.clone().unwrap_or_default()` chain
490    /// recurred at two hand-authored sites past the ★★ PRIME-DIRECTIVE
491    /// ≥ 2 duplication trigger, and is lifted to ONE owner here).
492    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
493    /// the pins bind the missing-namespace corner + the empty-string
494    /// sentinel byte-shape + the owned-form `String` return type + the
495    /// byte-identical parity with the pre-lift chain + the fallback-
496    /// value coherence with [`Self::owned_name_or_empty`] on the
497    /// paired-slot axis, so a regression that drifted any surface at
498    /// `tests::owned_namespace_or_empty_*` rather than as silent
499    /// operator-facing skew between the paired name / namespace halves
500    /// of the SAME `AllocationRef` seed).
501    pub fn owned_namespace_or_empty(&self) -> String {
502        self.metadata.namespace.clone().unwrap_or_default()
503    }
504}
505
506/// What the pool reconciler does when a member reaches `Failed`.
507///
508/// Sibling closed-set lifts on the same `tatara-process` axis:
509/// [`crate::compliance::VerificationPhase::ALL`],
510/// [`crate::signal::SighupStrategy::ALL`],
511/// [`crate::spec::MustReachPhase::ALL`],
512/// [`crate::intent::WorkloadKind::ALL`],
513/// [`crate::export::ReportFormat::ALL`],
514/// [`crate::encapsulates::EncapsulationMode::ALL`],
515/// [`crate::export::ExportTrigger::ALL`],
516/// [`crate::lifetime::TeardownPolicy::ALL`],
517/// [`crate::boundary::ConditionKind::ALL`],
518/// [`crate::lifetime::LifetimeKind::ALL`],
519/// [`crate::intent::IntentKind::ALL`],
520/// [`crate::phase::ProcessPhase::ALL`],
521/// [`crate::signal::ProcessSignal::ALL`].
522#[derive(
523    Clone,
524    Copy,
525    Debug,
526    Default,
527    Serialize,
528    Deserialize,
529    JsonSchema,
530    PartialEq,
531    Eq,
532    Hash,
533    tatara_closed_set::DeriveClosedSet,
534)]
535#[serde(rename_all = "PascalCase")]
536#[closed_set(via = "as_str", generate_unknown, display)]
537pub enum ReplacementPolicy {
538    /// **Default** — Failed member is reaped + replaced immediately
539    /// (pool stays at `desired` count). Most production-like.
540    #[default]
541    ReplaceImmediate,
542    /// Failed member stays for inspection; pool runs short until the
543    /// operator manually reaps it. Useful for debugging.
544    HoldFailed,
545    /// Failed member triggers pool-wide pause: `desired` is
546    /// effectively 0 until the operator manually resumes via a
547    /// pool-status patch. Used for "halt on any failure" workflows.
548    PausePool,
549}
550
551impl ReplacementPolicy {
552    /// The closed set of replacement policies — single source of truth
553    /// that drives the `as_str` / Display / `FromStr` triad and the
554    /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
555    /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
556    /// predicate arm per projection — exhaustively checked by the
557    /// compiler (the `[Self; 3]` array literal forces the arity) and by
558    /// the predicate-pair injectivity test below (a new variant must
559    /// land in its own (replaces_failed, pauses_on_failure) bucket or
560    /// the author has to extend the consumer dispatch in
561    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
562    pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
563
564    /// Canonical PascalCase wire-format projection — matches the serde
565    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
566    /// enumeration the pool reconciler stamps on the
567    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
568    /// `replacement_policy_as_str_matches_serde` so a variant rename
569    /// can't drift between the typed surface, the CRD enum, the YAML
570    /// wire format AND the operator-facing diagnostic (the
571    /// `desired.rs` Pause reason composes `policy={policy}` via
572    /// Display, not a hard-coded `"PausePool"` literal that would
573    /// silently rot).
574    pub const fn as_str(self) -> &'static str {
575        match self {
576            Self::ReplaceImmediate => "ReplaceImmediate",
577            Self::HoldFailed => "HoldFailed",
578            Self::PausePool => "PausePool",
579        }
580    }
581
582    /// Should the pool auto-spawn a replacement for a Failed member?
583    /// Closed-set match (not `matches!`) so a future variant triggers
584    /// the compiler's exhaustiveness check at this site rather than
585    /// silently defaulting to `false`. Paired with
586    /// `pauses_on_failure` they form the two-axis projection
587    /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
588    /// pattern-match against — `replaces_failed` true ⇒ emit
589    /// `ReapFailed` per failure; `pauses_on_failure` true with any
590    /// failure ⇒ emit `Pause` and short-circuit. The pair is
591    /// `(true, false) | (false, false) | (false, true)` — pinned
592    /// injective by `replacement_policy_predicate_pair_is_injective`.
593    pub const fn replaces_failed(self) -> bool {
594        match self {
595            Self::ReplaceImmediate => true,
596            Self::HoldFailed | Self::PausePool => false,
597        }
598    }
599
600    /// Should reaching Failed on any member pause the whole pool?
601    /// See `replaces_failed` for the closed-match rationale + the
602    /// predicate-pair contract.
603    pub const fn pauses_on_failure(self) -> bool {
604        match self {
605            Self::PausePool => true,
606            Self::ReplaceImmediate | Self::HoldFailed => false,
607        }
608    }
609}
610
611// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
612// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
613// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
614// declaration above. `label` delegates to the inherent
615// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
616// PascalCase wire-format projection stays load-bearing (matches the
617// serde `rename_all = "PascalCase"` output AND the
618// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
619// emission verbatim) while generic `T: ClosedSet` consumers reach the
620// STABLE workspace-wide name (`label`); Display delegates to the same
621// inherent projection via `#[closed_set(display)]` so the
622// `Pause` reason emitter's `policy={policy}` composition stays
623// pinned on the closed-set algebra rather than on a hand-rolled
624// `fmt::Display` block per implementor.
625
626// `pub struct UnknownReplacementPolicy(pub String)` is generated by
627// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
628// on the enum declaration above. The auto-derived label
629// `"replacement policy"` matches the prior hand-rolled
630// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
631// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
632// [`crate::export::UnknownReportFormat`],
633// [`crate::export::UnknownChannelKind`],
634// [`crate::export::UnknownExportTrigger`],
635// [`crate::lifetime::UnknownTeardownPolicy`],
636// [`crate::boundary::UnknownConditionKind`], and
637// [`crate::phase::UnknownPhase`].
638
639fn default_free_ttl() -> String {
640    "24h".to_string()
641}
642fn default_max_allocation_ttl() -> String {
643    "4h".to_string()
644}
645
646/// `EphemeralPool.status` — observed pool population state.
647#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
648#[serde(rename_all = "camelCase")]
649pub struct PoolStatus {
650    /// Pool lifecycle phase.
651    #[serde(default)]
652    pub phase: PoolPhase,
653
654    /// When the pool entered the current phase.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub phase_since: Option<DateTime<Utc>>,
657
658    /// Number of members currently in `Free` state (ready for allocation).
659    #[serde(default)]
660    pub ready_count: u32,
661
662    /// Number of members currently `Allocated`.
663    #[serde(default)]
664    pub allocated_count: u32,
665
666    /// Number of members currently `Spawning` (not yet Attested).
667    #[serde(default)]
668    pub spawning_count: u32,
669
670    /// Number of members currently `Returning` (reset or replace
671    /// in progress).
672    #[serde(default)]
673    pub returning_count: u32,
674
675    /// Member ledger — one entry per pool slot.
676    #[serde(default)]
677    pub members: Vec<PoolMember>,
678
679    /// Operator-visible message (e.g., "scaled down to floor").
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub message: Option<String>,
682
683    /// Standard Kubernetes Conditions.
684    #[serde(default)]
685    pub conditions: Vec<PoolCondition>,
686}
687
688/// One pool slot's state.
689#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
690#[serde(rename_all = "camelCase")]
691pub struct PoolMember {
692    /// `metadata.name` of the backing Process.
693    pub process_name: String,
694    /// Pool member's current slot state.
695    pub state: MemberState,
696    /// When the member entered the current state.
697    pub entered_state_at: DateTime<Utc>,
698    /// If allocated: the AllocationRef holding this slot.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub allocation_ref: Option<AllocationRef>,
701}
702
703/// Light reference to an `EphemeralAllocation`.
704#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
705#[serde(rename_all = "camelCase")]
706pub struct AllocationRef {
707    pub name: String,
708    pub namespace: String,
709}
710
711impl AllocationRef {
712    /// Substrate constructor for [`AllocationRef`]: composes the
713    /// `(name, namespace)` pair through ONE `impl Into<String>`-gated
714    /// entry point — the ONE-liner collapse of the paired
715    /// `AllocationRef { name: n.into(), namespace: ns.into() }`
716    /// struct-literal incantation every downstream consumer restated
717    /// by hand pre-lift.
718    ///
719    /// Pre-lift the `AllocationRef { name, namespace }` struct-literal
720    /// was hand-authored at FOUR production sites past the ★★ PRIME-
721    /// DIRECTIVE ≥ 2 duplication threshold across the workspace, all
722    /// composing an owned `(name: String, namespace: String)` pair
723    /// under one of two roles:
724    /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
725    ///   Bind path — the `assignedProcess` status slot's ref, pairing
726    ///   the just-bound member Process name with the allocation's
727    ///   containing namespace.
728    /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
729    ///   Release path — the same `assignedProcess` slot shape, stamped
730    ///   at the release-side status patch alongside the (unchanged)
731    ///   `boundPool` ref.
732    /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`
733    ///   pool-matched handle — the `matched_pool` slot's ref, pairing
734    ///   [`EphemeralPool::owned_name_or_empty`] with the pool's
735    ///   containing namespace.
736    /// * `tatara-github-watcher::allocation_factory::allocation_from_pr`
737    ///   — the `pool_ref` slot on the `AllocationSpec` emitted from a
738    ///   PullRequestEvent, pairing the operator-configured pool name
739    ///   with the watcher's target namespace.
740    ///
741    /// All FOUR sites walked the SAME two-field struct-literal shape
742    /// — an owned name half, an owned namespace half — differing only
743    /// in provenance. Post-lift each callsite reads
744    /// `AllocationRef::new(name, ns)` and the produced value feeds the
745    /// same downstream slot (`assignedProcess` / `bound_pool` /
746    /// `matched_pool` / `spec.pool_ref`) unchanged. The `impl Into<String>`
747    /// signature accepts every provenance the pre-lift sites carried —
748    /// owned `String` (the reconciler's owned-form projections), `&str`
749    /// (the factory's `n.to_string()` / `namespace.to_string()`
750    /// borrow-to-owned promotions), `Cow<str>`, and every other
751    /// `Into<String>` implementor — so no callsite has to change its
752    /// upstream provenance to route through the primitive.
753    ///
754    /// Return-form axis: owned [`AllocationRef`] — the wire-format
755    /// shape [`crate::pool::AllocationRef`]'s serde `rename_all =
756    /// "camelCase"` produces on both spec (`poolRef`) and status
757    /// (`boundPool` / `assignedProcess`) slots. The primitive owns
758    /// the axis-order `(name, namespace)` — the same order the four
759    /// consumers spelled — so a slot swap surfaces at the
760    /// `allocation_ref_new_positional_axis_order` pin below rather
761    /// than as silent `<namespace>/<name>` inversion downstream.
762    ///
763    /// Peer to the sibling substrate primitives already opened on the
764    /// pool-side (name, namespace) axis pair:
765    /// [`EphemeralPool::name_or_empty`] (borrow-form name),
766    /// [`EphemeralPool::owned_name_or_empty`] (owned-form name); this
767    /// constructor is the composer that folds the owned-form projections
768    /// into the wire-format ref shape.
769    ///
770    /// A future refactor of [`AllocationRef`]'s field set (a
771    /// `resource_kind: String` field for cross-CRD refs, an
772    /// `api_version: String` field for FQN references, a
773    /// canonicalization pass over the namespace half, a non-empty-name
774    /// gate) lands at ONE substrate constructor site here and every
775    /// downstream consumer inherits the upgrade mechanically — no per-
776    /// callsite hand-edit at the FOUR reconciler + factory sites.
777    ///
778    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
779    /// the `AllocationRef { name, namespace }` struct-literal shape
780    /// recurred at four hand-authored sites past the ★★ PRIME-
781    /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
782    /// here). THEORY.md §II.1 invariant 5 (composition preserves
783    /// proofs — the pins bind the positional axis-order + the
784    /// `Into<String>` provenance closure + byte-identical parity with
785    /// the pre-lift struct-literal + `PartialEq` coherence with the
786    /// hand-authored form, so a regression that reshaped any surface
787    /// at `tests::allocation_ref_new_*` rather than as silent
788    /// operator-facing skew between the assignedProcess / bound_pool
789    /// / matched_pool / spec.pool_ref slots on the SAME allocation).
790    #[must_use]
791    pub fn new(name: impl Into<String>, namespace: impl Into<String>) -> Self {
792        Self {
793            name: name.into(),
794            namespace: namespace.into(),
795        }
796    }
797}
798
799/// Per-slot state in the pool's free list.
800///
801/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
802/// (the on-failure policy that the pool reconciler dispatches against
803/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
804/// release-time disposition that transitions an [`Self::Allocated`]
805/// member into [`Self::Returning`] before it either re-enters
806/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
807#[derive(
808    Clone,
809    Copy,
810    Debug,
811    PartialEq,
812    Eq,
813    Hash,
814    Serialize,
815    Deserialize,
816    JsonSchema,
817    tatara_closed_set::DeriveClosedSet,
818)]
819#[serde(rename_all = "PascalCase")]
820#[closed_set(via = "as_str", generate_unknown, display)]
821pub enum MemberState {
822    /// Pool reconciler is creating/converging the backing Process.
823    Spawning,
824    /// Process is `Attested`; ready for allocation.
825    Free,
826    /// Held by an `EphemeralAllocation`.
827    Allocated,
828    /// Return policy is being applied (Reset → reset Job; Replace →
829    /// Process is being torn down and recreated).
830    Returning,
831    /// Permanent failure — the member needs operator attention.
832    Failed,
833}
834
835impl MemberState {
836    /// The closed set of member states — single source of truth that
837    /// drives the `as_str` / Display / `FromStr` triad AND the
838    /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
839    /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
840    /// arm per predicate — exhaustively checked by the compiler (the
841    /// `[Self; 5]` array literal forces the arity) and by the
842    /// per-variant truth-table contract test (a new variant must
843    /// declare its own `(is_failed, counts_toward_supply)` projection
844    /// or the consumer dispatch in
845    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
846    /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
847    /// will silently bucket it into the wrong lifecycle column).
848    pub const ALL: [Self; 5] = [
849        Self::Spawning,
850        Self::Free,
851        Self::Allocated,
852        Self::Returning,
853        Self::Failed,
854    ];
855
856    /// Canonical PascalCase wire-format projection — matches the serde
857    /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
858    /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
859    /// `status.members[].state`. Pinned by
860    /// `member_state_as_str_matches_serde` so a variant rename can't
861    /// drift between the typed surface, the CRD enum, the YAML wire
862    /// format AND any future operator-facing diagnostic that composes
863    /// `state={state}` via Display rather than a hard-coded literal
864    /// that would silently rot.
865    pub const fn as_str(self) -> &'static str {
866        match self {
867            Self::Spawning => "Spawning",
868            Self::Free => "Free",
869            Self::Allocated => "Allocated",
870            Self::Returning => "Returning",
871            Self::Failed => "Failed",
872        }
873    }
874
875    /// Is this member in a permanent-failure state — needs operator
876    /// attention? Closed-set match (not `matches!`) so a future variant
877    /// triggers the compiler's exhaustiveness check at this site rather
878    /// than silently defaulting to `false`. Consumed by
879    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
880    /// gate the highest-priority `ReplaceMembers` decision branch — a
881    /// future variant that should also trigger replacement (e.g.
882    /// `MemberState::Quarantined`) flips this predicate at one site
883    /// and inherits the priority-1 dispatch without touching the
884    /// consumer match arm.
885    pub const fn is_failed(self) -> bool {
886        match self {
887            Self::Failed => true,
888            Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
889        }
890    }
891
892    /// Does this member contribute to the pool's *available supply*
893    /// (current ready slots + slots coming online)? Closed-set match so
894    /// a future variant triggers the compiler's exhaustiveness check.
895    /// Consumed by
896    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
897    /// — the `(free + spawning)` supply calc collapses into one
898    /// predicate-driven filter, so a future "warming-up" state
899    /// (`MemberState::Warming` between Spawning and Free) plugs into
900    /// the supply count at one site rather than three. Disjoint with
901    /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
902    /// (a Failed member can never count toward supply; the pool
903    /// reconciler would otherwise double-count failures as available
904    /// capacity).
905    pub const fn counts_toward_supply(self) -> bool {
906        match self {
907            Self::Free | Self::Spawning => true,
908            Self::Allocated | Self::Returning | Self::Failed => false,
909        }
910    }
911}
912
913// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
914// MemberState` + `impl fmt::Display for MemberState` are generated by
915// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
916// above. `label` delegates to the inherent `MemberState::as_str` via
917// `#[closed_set(via = "as_str")]` so the
918// `pool_phase_from_members` supply calc can keep keying on
919// `counts_toward_supply` against the typed variant while a generic
920// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
921// (`label`) without knowing this enum lives in `tatara-process::pool`;
922// Display delegates to the same inherent projection via
923// `#[closed_set(display)]` so the diagnostic emitter's
924// `state={state}` composition stays pinned on the closed-set algebra.
925
926// `pub struct UnknownMemberState(pub String)` is generated by
927// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
928// on the enum declaration above. The auto-derived label `"member state"`
929// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
930// verbatim. Symmetric to [`UnknownReplacementPolicy`],
931// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
932// [`crate::lifetime::UnknownTeardownPolicy`],
933// [`crate::boundary::UnknownConditionKind`], and
934// [`crate::phase::UnknownPhase`].
935
936/// Pool lifecycle phase (observed across the whole pool population).
937///
938/// Sibling closed-set on the same `EphemeralPool` axis as
939/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
940/// over via [`MemberState::counts_toward_supply`]),
941/// [`ReplacementPolicy::ALL`] (on-failure policy) and
942/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
943/// `MemberState`, this closes the pool reconciler's
944/// `(slot-state, pool-phase)` two-tier observation algebra on the
945/// same closed-set discipline as the rest of `tatara-process`.
946#[derive(
947    Clone,
948    Copy,
949    Debug,
950    PartialEq,
951    Eq,
952    Hash,
953    Serialize,
954    Deserialize,
955    JsonSchema,
956    tatara_closed_set::DeriveClosedSet,
957)]
958#[serde(rename_all = "PascalCase")]
959#[closed_set(via = "as_str", generate_unknown, display)]
960pub enum PoolPhase {
961    /// Just admitted; no members yet.
962    Initializing,
963    /// `ready_count == desired_size`.
964    Steady,
965    /// `ready_count + spawning_count < desired_size` and reconciler
966    /// is creating new members.
967    ScalingUp,
968    /// `ready_count > desired_size` and reconciler is reaping excess.
969    ScalingDown,
970    /// `min_size` constraint violated.
971    Degraded,
972    /// Pool is being deleted; reconciler is reaping all members.
973    Draining,
974}
975
976impl Default for PoolPhase {
977    fn default() -> Self {
978        Self::Initializing
979    }
980}
981
982impl PoolPhase {
983    /// The closed set of pool phases — single source of truth that
984    /// drives the `as_str` / Display / `FromStr` triad AND the
985    /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
986    /// variant lands at one `ALL` entry + one `as_str` arm + one arm
987    /// per predicate — exhaustively checked by the compiler (the
988    /// `[Self; 6]` array literal forces the arity) AND by the
989    /// per-variant truth-table contract test (a new variant must
990    /// declare its own `(is_steady, is_terminal)` projection or any
991    /// future status-aggregator surface — `feira pool list
992    /// --healthy`, the operator-facing condition aggregator, the
993    /// desired-loop heartbeat short-circuit — will silently bucket
994    /// it into the wrong lifecycle column).
995    pub const ALL: [Self; 6] = [
996        Self::Initializing,
997        Self::Steady,
998        Self::ScalingUp,
999        Self::ScalingDown,
1000        Self::Degraded,
1001        Self::Draining,
1002    ];
1003
1004    /// Canonical PascalCase wire-format projection — matches the
1005    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1006    /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
1007    /// stamps on `status.phase`. Pinned by
1008    /// `pool_phase_as_str_matches_serde` so a variant rename can't
1009    /// drift between the typed surface, the CRD enum, the YAML wire
1010    /// format AND any future operator-facing diagnostic that
1011    /// composes `phase={phase}` via Display rather than a hard-coded
1012    /// literal that would silently rot. Display + FromStr triad
1013    /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
1014    /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
1015    /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
1016    pub const fn as_str(self) -> &'static str {
1017        match self {
1018            Self::Initializing => "Initializing",
1019            Self::Steady => "Steady",
1020            Self::ScalingUp => "ScalingUp",
1021            Self::ScalingDown => "ScalingDown",
1022            Self::Degraded => "Degraded",
1023            Self::Draining => "Draining",
1024        }
1025    }
1026
1027    /// Is the pool fully converged — supply matches desired, no
1028    /// reconciler-driven population change pending? Closed-set match
1029    /// (not `matches!`) so a future variant triggers the compiler's
1030    /// exhaustiveness check at this site rather than silently
1031    /// defaulting to `false`. Paired with `is_terminal` they form
1032    /// the two-axis projection that future status aggregators
1033    /// (operator-facing fleet health, `feira pool list --healthy`,
1034    /// the SSE filter "show non-steady pools") dispatch against —
1035    /// `is_steady && !is_terminal` ⇒ converged (goal state);
1036    /// `!is_steady && is_terminal` ⇒ being deleted (no future
1037    /// spawn); `!is_steady && !is_terminal` ⇒ transient
1038    /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
1039    /// is in motion toward desired). The impossible bucket
1040    /// `(true, true)` — a draining pool that's somehow also steady
1041    /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
1042    pub const fn is_steady(self) -> bool {
1043        match self {
1044            Self::Steady => true,
1045            Self::Initializing
1046            | Self::ScalingUp
1047            | Self::ScalingDown
1048            | Self::Degraded
1049            | Self::Draining => false,
1050        }
1051    }
1052
1053    /// Is the pool in its absorbing exit state — deletion-stamped,
1054    /// reconciler is reaping every member, no spawn will ever
1055    /// happen again? Closed-set match so a future variant triggers
1056    /// the compiler's exhaustiveness check. See `is_steady` for the
1057    /// predicate-pair contract + bucket definitions.
1058    pub const fn is_terminal(self) -> bool {
1059        match self {
1060            Self::Draining => true,
1061            Self::Initializing
1062            | Self::Steady
1063            | Self::ScalingUp
1064            | Self::ScalingDown
1065            | Self::Degraded => false,
1066        }
1067    }
1068}
1069
1070// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
1071// + `impl fmt::Display for PoolPhase` are generated by
1072// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
1073// `label` delegates to the inherent `PoolPhase::as_str` via
1074// `#[closed_set(via = "as_str")]` so the operator-facing
1075// `phase={phase}` Display composition keeps reading the same canonical
1076// PascalCase projection while a generic `T: ClosedSet` consumer (a
1077// status-aggregator filter, the `feira pool list --healthy` predicate, a
1078// future SSE event router) can walk every variant without knowing the
1079// closed set lives in `tatara-process::pool`; Display delegates to the
1080// same inherent projection via `#[closed_set(display)]` so the
1081// `phase={phase}` composition stays pinned on the closed-set algebra
1082// rather than a hand-rolled `fmt::Display` block.
1083
1084// `pub struct UnknownPoolPhase(pub String)` is generated by
1085// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1086// on the enum declaration above. The auto-derived label `"pool phase"`
1087// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
1088// verbatim. Symmetric to [`UnknownMemberState`],
1089// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
1090// [`crate::lifetime::UnknownTeardownPolicy`],
1091// [`crate::boundary::UnknownConditionKind`], and
1092// [`crate::phase::UnknownPhase`].
1093
1094/// Standard K8s Condition shape (kept local so tatara-process doesn't
1095/// depend on k8s_openapi types in its public schema).
1096#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
1097#[serde(rename_all = "camelCase")]
1098pub struct PoolCondition {
1099    pub type_: String,
1100    pub status: String,
1101    pub reason: String,
1102    pub message: String,
1103    pub last_transition_time: DateTime<Utc>,
1104}
1105
1106/// What the pool does when an allocation releases a member.
1107///
1108/// Sibling closed-set on the `EphemeralPool` axis:
1109/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
1110/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
1111/// (the *release*-time counterpart for non-pooled ephemeral envs),
1112/// [`crate::boundary::ConditionKind::ALL`],
1113/// [`crate::lifetime::LifetimeKind::ALL`],
1114/// [`crate::intent::IntentKind::ALL`],
1115/// [`crate::phase::ProcessPhase::ALL`],
1116/// [`crate::signal::ProcessSignal::ALL`].
1117#[derive(
1118    Clone,
1119    Copy,
1120    Debug,
1121    Hash,
1122    PartialEq,
1123    Eq,
1124    Serialize,
1125    Deserialize,
1126    JsonSchema,
1127    Default,
1128    tatara_closed_set::DeriveClosedSet,
1129)]
1130#[serde(rename_all = "PascalCase")]
1131#[closed_set(via = "as_str", generate_unknown, display)]
1132pub enum ReturnPolicy {
1133    /// Tear down the Process + create a fresh one. Safe but slow
1134    /// (1-2 min spin-up before the slot is Free again).
1135    #[default]
1136    Replace,
1137    /// Keep the Process running; run a typed `:reset` Job that wipes
1138    /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
1139    /// the reset Job being correct for the workload. API-authoritative
1140    /// systems are natural fits because the control API owns all state.
1141    Reset,
1142    /// Keep the Process indefinitely after release (debugging aid;
1143    /// operator must `feira pool reap NAME` to clean up). Useful for
1144    /// post-mortem of a flaky test.
1145    Keep,
1146}
1147
1148impl ReturnPolicy {
1149    /// The closed set of return policies — single source of truth that
1150    /// drives the `as_str` / Display / `FromStr` triad and the
1151    /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
1152    /// fourth variant lands at one `ALL` entry + one `as_str` arm +
1153    /// one arm per predicate — exhaustively checked by the compiler
1154    /// (the `[Self; 3]` array literal forces the arity) and by the
1155    /// predicate-pair injectivity test (a new variant must land in
1156    /// its own (keeps_process, runs_reset_job) bucket or the author
1157    /// has to extend the consumer dispatch in
1158    /// `tatara-pool-reconciler::return_policy::plan_return`).
1159    pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
1160
1161    /// Canonical PascalCase wire-format projection — matches the
1162    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1163    /// `enum:` enumeration the pool reconciler stamps on the
1164    /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
1165    /// `return_policy_as_str_matches_serde` so a variant rename can't
1166    /// drift between the typed surface, the CRD enum, the YAML wire
1167    /// format AND any future operator-facing diagnostic that composes
1168    /// `policy={policy}` via Display rather than a hard-coded literal.
1169    pub const fn as_str(self) -> &'static str {
1170        match self {
1171            Self::Replace => "Replace",
1172            Self::Reset => "Reset",
1173            Self::Keep => "Keep",
1174        }
1175    }
1176
1177    /// Does the pool keep the backing Process alive across release?
1178    /// Closed-set match (not `matches!`) so a future variant triggers
1179    /// the compiler's exhaustiveness check at this site rather than
1180    /// silently defaulting to `false`. Paired with `runs_reset_job`
1181    /// they form the two-axis projection that the consumer in
1182    /// `tatara-pool-reconciler::return_policy::plan_return` matches
1183    /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
1184    /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
1185    /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
1186    /// pair is `(false, false) | (true, true) | (true, false)` —
1187    /// pinned injective by
1188    /// `return_policy_predicate_pair_is_injective`.
1189    pub const fn keeps_process(self) -> bool {
1190        match self {
1191            Self::Replace => false,
1192            Self::Reset | Self::Keep => true,
1193        }
1194    }
1195
1196    /// Does the policy run a typed `:reset` Job to wipe state in
1197    /// place? See `keeps_process` for the closed-match rationale +
1198    /// the predicate-pair contract.
1199    pub const fn runs_reset_job(self) -> bool {
1200        match self {
1201            Self::Reset => true,
1202            Self::Replace | Self::Keep => false,
1203        }
1204    }
1205}
1206
1207// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
1208// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
1209// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1210// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
1211// `#[closed_set(via = "as_str")]` so the
1212// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
1213// reading the canonical PascalCase projection that matches the CRD
1214// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
1215// plugs in without knowing the enum lives in `tatara-process::pool`;
1216// Display delegates to the same inherent projection via
1217// `#[closed_set(display)]` so the `policy={policy}` diagnostic
1218// composition stays pinned on the closed-set algebra.
1219
1220// `pub struct UnknownReturnPolicy(pub String)` is generated by
1221// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1222// on the enum declaration above. The auto-derived label `"return policy"`
1223// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
1224// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1225// [`UnknownMemberState`], [`UnknownPoolPhase`],
1226// [`crate::lifetime::UnknownTeardownPolicy`],
1227// [`crate::boundary::UnknownConditionKind`], and
1228// [`crate::phase::UnknownPhase`].
1229
1230/// Routing selector — matches an `EphemeralAllocation`'s requestor
1231/// against pool-eligibility predicates.
1232#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1233#[serde(rename_all = "camelCase")]
1234pub struct PoolSelector {
1235    /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
1236    /// Empty = match every repo.
1237    #[serde(default)]
1238    pub repos: Vec<String>,
1239
1240    /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
1241    /// Empty = match every branch.
1242    #[serde(default)]
1243    pub branches: Vec<String>,
1244
1245    /// PR labels (all-must-match, AND semantics). Empty = no label
1246    /// requirement.
1247    #[serde(default)]
1248    pub pr_labels: Vec<String>,
1249
1250    /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
1251    /// "manual", "ci-run"). Empty = any kind.
1252    #[serde(default)]
1253    pub kinds: Vec<String>,
1254}
1255
1256impl PoolSelector {
1257    /// Does this selector match the given allocation routing key?
1258    /// Pure: no side effects.
1259    pub fn matches(&self, key: &MatchKey<'_>) -> bool {
1260        glob_any(&self.repos, key.repo)
1261            && glob_any(&self.branches, key.branch)
1262            && labels_subset(&self.pr_labels, key.pr_labels)
1263            && kind_any(&self.kinds, key.kind)
1264    }
1265
1266    /// Specificity score — higher = more specific. Used by the
1267    /// reconciler to break ties between selectors that all match.
1268    pub fn specificity(&self) -> u32 {
1269        let mut score = 0;
1270        if !self.repos.is_empty() {
1271            score += 8;
1272        }
1273        if !self.branches.is_empty() {
1274            score += 4;
1275        }
1276        score += (self.pr_labels.len() as u32) * 2;
1277        if !self.kinds.is_empty() {
1278            score += 1;
1279        }
1280        score
1281    }
1282}
1283
1284/// Allocation routing key — what the reconciler matches against pool selectors.
1285#[derive(Clone, Copy, Debug)]
1286pub struct MatchKey<'a> {
1287    pub repo: &'a str,
1288    pub branch: &'a str,
1289    pub pr_labels: &'a [String],
1290    pub kind: &'a str,
1291}
1292
1293fn glob_any(patterns: &[String], value: &str) -> bool {
1294    if patterns.is_empty() {
1295        return true;
1296    }
1297    patterns.iter().any(|p| glob_match(p, value))
1298}
1299
1300fn kind_any(kinds: &[String], value: &str) -> bool {
1301    if kinds.is_empty() {
1302        return true;
1303    }
1304    kinds.iter().any(|k| k == value)
1305}
1306
1307fn labels_subset(required: &[String], present: &[String]) -> bool {
1308    required.iter().all(|r| present.iter().any(|p| p == r))
1309}
1310
1311/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
1312/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
1313/// matches anything.
1314fn glob_match(pattern: &str, value: &str) -> bool {
1315    if pattern.is_empty() {
1316        return true;
1317    }
1318    if let Some(prefix) = pattern.strip_suffix('*') {
1319        value.starts_with(prefix)
1320    } else {
1321        pattern == value
1322    }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327    use super::*;
1328    // The closed-set tests below call `T::from_str(bad)` via the
1329    // derive-generated `FromStr` impls — bring the trait into scope at
1330    // the test module so the lib body doesn't carry an otherwise-unused
1331    // `use std::str::FromStr;` at the file head.
1332    use std::str::FromStr;
1333
1334    #[test]
1335    fn glob_trailing_star_matches_prefix() {
1336        assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
1337        assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
1338        assert!(glob_match("release-*", "release-2026-05"));
1339        assert!(!glob_match("release-*", "main"));
1340        assert!(glob_match("main", "main"));
1341        assert!(!glob_match("main", "develop"));
1342    }
1343
1344    #[test]
1345    fn empty_selector_matches_anything() {
1346        let s = PoolSelector::default();
1347        assert!(s.matches(&MatchKey {
1348            repo: "any/repo",
1349            branch: "any-branch",
1350            pr_labels: &[],
1351            kind: "any",
1352        }));
1353    }
1354
1355    #[test]
1356    fn repo_glob_filters_match_key() {
1357        let s = PoolSelector {
1358            repos: vec!["pleme-io/demo-*".into()],
1359            ..Default::default()
1360        };
1361        assert!(s.matches(&MatchKey {
1362            repo: "pleme-io/demo-app",
1363            branch: "x",
1364            pr_labels: &[],
1365            kind: "y",
1366        }));
1367        assert!(!s.matches(&MatchKey {
1368            repo: "pleme-io/other-repo",
1369            branch: "x",
1370            pr_labels: &[],
1371            kind: "y",
1372        }));
1373    }
1374
1375    #[test]
1376    fn pr_labels_require_all() {
1377        let s = PoolSelector {
1378            pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
1379            ..Default::default()
1380        };
1381        // Both labels present → match.
1382        assert!(s.matches(&MatchKey {
1383            repo: "x",
1384            branch: "y",
1385            pr_labels: &[
1386                "needs-ephemeral".into(),
1387                "integration".into(),
1388                "extra".into()
1389            ],
1390            kind: "z",
1391        }));
1392        // One label missing → no match.
1393        assert!(!s.matches(&MatchKey {
1394            repo: "x",
1395            branch: "y",
1396            pr_labels: &["needs-ephemeral".into()],
1397            kind: "z",
1398        }));
1399    }
1400
1401    #[test]
1402    fn specificity_ranks_more_constrained_higher() {
1403        let general = PoolSelector::default();
1404        let specific = PoolSelector {
1405            repos: vec!["pleme-io/*".into()],
1406            branches: vec!["main".into()],
1407            pr_labels: vec!["needs-ephemeral".into()],
1408            kinds: vec!["github-pr".into()],
1409        };
1410        assert!(specific.specificity() > general.specificity());
1411    }
1412
1413    #[test]
1414    fn return_policy_defaults_to_replace() {
1415        assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
1416    }
1417
1418    #[test]
1419    fn pool_phase_defaults_to_initializing() {
1420        assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
1421    }
1422
1423    // ── closed-set algebra contracts for ReplacementPolicy
1424    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
1425
1426    /// Structural well-formedness of [`ReplacementPolicy`] as a
1427    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1428    /// testkit lift that pins all three structural invariants (`ALL`
1429    /// is non-empty, every variant round-trips through
1430    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1431    /// outside the closed set) at ONE call site. Replaces the hand-
1432    /// derived `replacement_policy_all_is_unique_and_complete` +
1433    /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
1434    /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
1435    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1436    /// exercises the same code path the pool reconciler hits when
1437    /// parsing a CRD `enum:`-validated value back to the typed policy.
1438    #[test]
1439    fn replacement_policy_is_well_formed_closed_set() {
1440        tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
1441    }
1442
1443    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1444    /// output verbatim for every variant. A future variant rename (or
1445    /// an `as_str` arm typo) lands here at one site, instead of
1446    /// drifting between the typed surface, the CRD enum, and the
1447    /// YAML wire format.
1448    #[test]
1449    fn replacement_policy_as_str_matches_serde() {
1450        crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
1451    }
1452
1453    /// The Display impl IS `as_str` — pinning this lets future callers
1454    /// reach for either projection without drift. The operator-facing
1455    /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1456    /// composes through Display rather than through a hard-coded
1457    /// variant string.
1458    #[test]
1459    fn replacement_policy_display_matches_as_str() {
1460        crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
1461    }
1462
1463    /// `FromStr` rejects strings that aren't in the canonical
1464    /// projection — lowercased / typo / cross-axis-leaked — and the
1465    /// error echoes the input verbatim so the operator-facing
1466    /// diagnostic carries the offending value, not a normalized form.
1467    /// The empty-input arm is pinned by
1468    /// [`replacement_policy_is_well_formed_closed_set`] via the
1469    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1470    /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1471    /// newtype, which the trait's `make_unknown` can't see.
1472    #[test]
1473    fn unknown_replacement_policy_errors() {
1474        for bad in [
1475            "replaceimmediate",
1476            "PAUSEPOOL",
1477            "Replace-Immediate",
1478            "hold_failed",
1479            "Pause",
1480            "Reset",
1481        ] {
1482            let err = ReplacementPolicy::from_str(bad).unwrap_err();
1483            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1484        }
1485    }
1486
1487    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1488    /// documented per-variant on-failure behavior.
1489    #[test]
1490    fn replacement_policy_predicate_truth_tables() {
1491        assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1492        assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1493
1494        assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1495        assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1496
1497        assert!(!ReplacementPolicy::PausePool.replaces_failed());
1498        assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1499    }
1500
1501    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1502    /// predicates simultaneously — the two on-failure actions
1503    /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1504    /// A future `ReplacementPolicy::PauseAndReap` that returned true
1505    /// from both would FAIL here, forcing the author to either pick
1506    /// one bucket or extend the consumer dispatch site in
1507    /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1508    /// deliberately rather than silently double-firing both branches.
1509    #[test]
1510    fn replacement_policy_predicates_are_disjoint() {
1511        for policy in ReplacementPolicy::ALL {
1512            assert!(
1513                !(policy.replaces_failed() && policy.pauses_on_failure()),
1514                "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1515            );
1516        }
1517    }
1518
1519    /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1520    /// pauses_on_failure)` is injective across `ALL`. Each variant
1521    /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1522    /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1523    /// this with the disjointness contract above forces a future
1524    /// variant to land in a fresh `(replaces_failed,
1525    /// pauses_on_failure)` bucket — or the author extends the consumer
1526    /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1527    /// to recognize the new projection bucket.
1528    #[test]
1529    fn replacement_policy_predicate_pair_is_injective() {
1530        let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1531            .into_iter()
1532            .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1533            .collect();
1534        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1535        assert_eq!(
1536            projections.len(),
1537            unique.len(),
1538            "predicate pair projection is not injective: {projections:?}",
1539        );
1540    }
1541
1542    /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1543    /// returns the variant tagged `#[default]` in the enum, AND that
1544    /// variant reaps (the production-safe behavior). A future #[default]
1545    /// rename without flipping the predicates fails here.
1546    #[test]
1547    fn replacement_policy_default_replaces_failed() {
1548        let d = ReplacementPolicy::default();
1549        assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1550        assert!(d.replaces_failed());
1551        assert!(!d.pauses_on_failure());
1552    }
1553
1554    #[test]
1555    fn kinds_filter_to_known_set() {
1556        let s = PoolSelector {
1557            kinds: vec!["github-pr".into(), "manual".into()],
1558            ..Default::default()
1559        };
1560        assert!(s.matches(&MatchKey {
1561            repo: "x",
1562            branch: "y",
1563            pr_labels: &[],
1564            kind: "github-pr",
1565        }));
1566        assert!(!s.matches(&MatchKey {
1567            repo: "x",
1568            branch: "y",
1569            pr_labels: &[],
1570            kind: "scheduled",
1571        }));
1572    }
1573
1574    // ── closed-set algebra contracts for ReturnPolicy
1575    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
1576
1577    /// Structural well-formedness of [`ReturnPolicy`] as a
1578    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1579    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1580    /// above.
1581    #[test]
1582    fn return_policy_is_well_formed_closed_set() {
1583        tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1584    }
1585
1586    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1587    /// output verbatim for every variant. A future variant rename (or
1588    /// an `as_str` arm typo) lands here at one site, instead of
1589    /// drifting between the typed surface, the CRD enum, and the
1590    /// YAML wire format.
1591    #[test]
1592    fn return_policy_as_str_matches_serde() {
1593        crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
1594    }
1595
1596    /// The Display impl IS `as_str` — pinning this lets future callers
1597    /// reach for either projection without drift, mirroring the
1598    /// `ReplacementPolicy` discipline.
1599    #[test]
1600    fn return_policy_display_matches_as_str() {
1601        crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
1602    }
1603
1604    /// `FromStr` rejects strings that aren't in the canonical
1605    /// projection — lowercased / typo / cross-axis-leaked — and the
1606    /// error echoes the input verbatim so the operator-facing
1607    /// diagnostic carries the offending value, not a normalized form.
1608    /// The empty-input arm is pinned by
1609    /// [`return_policy_is_well_formed_closed_set`] via the
1610    /// `tatara_lisp::ClosedSet` testkit.
1611    #[test]
1612    fn unknown_return_policy_errors() {
1613        for bad in [
1614            "replace",
1615            "RESET",
1616            "Re-place",
1617            "keep_for_inspection",
1618            "DeleteAndRespawn",
1619            "ReplaceImmediate",
1620        ] {
1621            let err = ReturnPolicy::from_str(bad).unwrap_err();
1622            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1623        }
1624    }
1625
1626    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1627    /// documented per-variant on-release behavior.
1628    #[test]
1629    fn return_policy_predicate_truth_tables() {
1630        assert!(!ReturnPolicy::Replace.keeps_process());
1631        assert!(!ReturnPolicy::Replace.runs_reset_job());
1632
1633        assert!(ReturnPolicy::Reset.keeps_process());
1634        assert!(ReturnPolicy::Reset.runs_reset_job());
1635
1636        assert!(ReturnPolicy::Keep.keeps_process());
1637        assert!(!ReturnPolicy::Keep.runs_reset_job());
1638    }
1639
1640    /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1641    /// You cannot run a typed `:reset` Job against a Process you've
1642    /// just deleted; the impossible bucket `(false, true)` must stay
1643    /// empty. A future variant returning true from `runs_reset_job`
1644    /// while returning false from `keeps_process` fails here, which
1645    /// forces the author to either flip `keeps_process` to true or
1646    /// extend the consumer dispatch site in
1647    /// `tatara-pool-reconciler::return_policy::plan_return`
1648    /// deliberately rather than letting an impossible state slip in.
1649    #[test]
1650    fn return_policy_reset_implies_keeps_process() {
1651        for policy in ReturnPolicy::ALL {
1652            if policy.runs_reset_job() {
1653                assert!(
1654                    policy.keeps_process(),
1655                    "{policy:?} runs a reset job but does not keep the process",
1656                );
1657            }
1658        }
1659    }
1660
1661    /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1662    /// is injective across `ALL`. Each variant projects to its own
1663    /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1664    /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1665    /// inspection. Pairing this with the implication contract above
1666    /// forces a future variant to land in a fresh
1667    /// `(keeps_process, runs_reset_job)` bucket — or the author
1668    /// extends the consumer dispatch in
1669    /// `tatara-pool-reconciler::return_policy::plan_return` to
1670    /// recognize the new projection bucket.
1671    #[test]
1672    fn return_policy_predicate_pair_is_injective() {
1673        let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1674            .into_iter()
1675            .map(|p| (p.keeps_process(), p.runs_reset_job()))
1676            .collect();
1677        let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1678        assert_eq!(
1679            projections.len(),
1680            unique.len(),
1681            "predicate pair projection is not injective: {projections:?}",
1682        );
1683    }
1684
1685    /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1686    /// the variant tagged `#[default]` in the enum, AND that variant
1687    /// is the safe "tear down + respawn" behavior — neither keeps the
1688    /// process nor runs a reset Job. A future `#[default]` rename
1689    /// without flipping the predicates fails here.
1690    #[test]
1691    fn return_policy_default_is_replace_and_neither_predicate_fires() {
1692        let d = ReturnPolicy::default();
1693        assert_eq!(d, ReturnPolicy::Replace);
1694        assert!(!d.keeps_process());
1695        assert!(!d.runs_reset_job());
1696    }
1697
1698    // ── closed-set algebra contracts for MemberState
1699    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1700
1701    /// Structural well-formedness of [`MemberState`] as a
1702    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1703    /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1704    /// and [`return_policy_is_well_formed_closed_set`] above.
1705    #[test]
1706    fn member_state_is_well_formed_closed_set() {
1707        tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1708    }
1709
1710    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1711    /// output verbatim for every variant. A future variant rename (or
1712    /// an `as_str` arm typo) lands here at one site, instead of
1713    /// drifting between the typed surface, the CRD enum, and the YAML
1714    /// wire format the pool reconciler stamps on
1715    /// `status.members[].state`.
1716    #[test]
1717    fn member_state_as_str_matches_serde() {
1718        crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
1719    }
1720
1721    /// The Display impl IS `as_str` — pinning this lets future callers
1722    /// reach for either projection without drift. Any operator-facing
1723    /// "state={state}" diagnostic that composes through Display
1724    /// inherits the canonical wire-format string automatically.
1725    #[test]
1726    fn member_state_display_matches_as_str() {
1727        crate::tagged_union::assert_display_matches_label::<MemberState>();
1728    }
1729
1730    /// `FromStr` rejects strings that aren't in the canonical
1731    /// projection — lowercased / typo / cross-axis-leaked — and
1732    /// the error echoes the input verbatim so the operator-facing
1733    /// diagnostic carries the offending value, not a normalized form.
1734    /// The empty-input arm is pinned by
1735    /// [`member_state_is_well_formed_closed_set`] via the
1736    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1737    /// pin the closed-set REJECTION contract that the trait can't see:
1738    /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1739    /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1740    /// `ProcessPhase`) but MUST reject here, because the codomains
1741    /// are disjoint.
1742    #[test]
1743    fn unknown_member_state_errors() {
1744        for bad in [
1745            "free",
1746            "SPAWNING",
1747            "Free-State",
1748            "allocated_now",
1749            "ReplaceImmediate", // ReplacementPolicy-axis leak
1750            "Reset",            // ReturnPolicy-axis leak
1751            "Attested",         // ProcessPhase-axis leak
1752        ] {
1753            let err = MemberState::from_str(bad).unwrap_err();
1754            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1755        }
1756    }
1757
1758    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1759    /// documented per-variant lifecycle role. The pool reconciler's
1760    /// `pool_phase_from_members` supply calc collapses
1761    /// `count_state(Free) + count_state(Spawning)` into one
1762    /// `counts_toward_supply` filter; this table pins the per-variant
1763    /// projection that consumer depends on.
1764    #[test]
1765    fn member_state_predicate_truth_tables() {
1766        assert!(!MemberState::Spawning.is_failed());
1767        assert!(MemberState::Spawning.counts_toward_supply());
1768
1769        assert!(!MemberState::Free.is_failed());
1770        assert!(MemberState::Free.counts_toward_supply());
1771
1772        assert!(!MemberState::Allocated.is_failed());
1773        assert!(!MemberState::Allocated.counts_toward_supply());
1774
1775        assert!(!MemberState::Returning.is_failed());
1776        assert!(!MemberState::Returning.counts_toward_supply());
1777
1778        assert!(MemberState::Failed.is_failed());
1779        assert!(!MemberState::Failed.counts_toward_supply());
1780    }
1781
1782    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1783    /// `is_failed` and `counts_toward_supply` simultaneously — a
1784    /// failed member can never be counted as available capacity. A
1785    /// future variant that returned true from both would FAIL here,
1786    /// forcing the author to either drop it from supply, or extend
1787    /// the consumer's bucketing in
1788    /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1789    /// deliberately rather than silently inflating the pool's supply
1790    /// count with failed slots.
1791    #[test]
1792    fn member_state_failed_implies_no_supply() {
1793        for state in MemberState::ALL {
1794            assert!(
1795                !(state.is_failed() && state.counts_toward_supply()),
1796                "{state:?} returns true from both is_failed and counts_toward_supply — \
1797                 a failed member can never be counted as available pool capacity",
1798            );
1799        }
1800    }
1801
1802    /// COVERAGE CONTRACT: every variant lands somewhere — either
1803    /// in supply, or as a failed slot, or as an in-use bucket
1804    /// (`Allocated | Returning`). A future variant that returns
1805    /// `false` from `counts_toward_supply` AND `false` from
1806    /// `is_failed` is fine *iff* it represents an in-use slot; this
1807    /// test pins the existing variants in their declared buckets so
1808    /// the consumer-side dispatch in
1809    /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1810    /// stays grounded.
1811    #[test]
1812    fn member_state_buckets_cover_every_variant() {
1813        let mut supply = 0u32;
1814        let mut failed = 0u32;
1815        let mut in_use = 0u32;
1816        for state in MemberState::ALL {
1817            match (state.is_failed(), state.counts_toward_supply()) {
1818                (true, false) => failed += 1,
1819                (false, true) => supply += 1,
1820                (false, false) => in_use += 1,
1821                (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1822            }
1823        }
1824        assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1825        assert_eq!(failed, 1, "failed bucket: Failed");
1826        assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1827        assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1828    }
1829
1830    // ── closed-set algebra contracts for PoolPhase
1831    //    (ALL × as_str × FromStr × predicate pair) ────────────────────
1832
1833    /// Structural well-formedness of [`PoolPhase`] as a
1834    /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1835    /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1836    #[test]
1837    fn pool_phase_is_well_formed_closed_set() {
1838        tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1839    }
1840
1841    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1842    /// output verbatim for every variant. A future variant rename (or
1843    /// an `as_str` arm typo) lands here at one site, instead of
1844    /// drifting between the typed surface, the CRD enum, and the YAML
1845    /// wire format the pool reconciler stamps on `status.phase`.
1846    #[test]
1847    fn pool_phase_as_str_matches_serde() {
1848        crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
1849    }
1850
1851    /// The Display impl IS `as_str` — pinning this lets future callers
1852    /// reach for either projection without drift. Any operator-facing
1853    /// "phase={phase}" diagnostic that composes through Display
1854    /// inherits the canonical wire-format string automatically.
1855    #[test]
1856    fn pool_phase_display_matches_as_str() {
1857        crate::tagged_union::assert_display_matches_label::<PoolPhase>();
1858    }
1859
1860    /// `FromStr` rejects strings that aren't in the canonical
1861    /// projection — lowercased / typo / cross-axis-leaked — and
1862    /// the error echoes the input verbatim so the operator-facing
1863    /// diagnostic carries the offending value, not a normalized form.
1864    /// The empty-input arm is pinned by
1865    /// [`pool_phase_is_well_formed_closed_set`] via the
1866    /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1867    /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1868    /// closed-set REJECTION contract that the trait can't see — those
1869    /// are valid sibling-axis labels but MUST reject here.
1870    #[test]
1871    fn unknown_pool_phase_errors() {
1872        for bad in [
1873            "steady",
1874            "SCALINGUP",
1875            "Scaling-Up",
1876            "scaling_down",
1877            "Free",       // MemberState-axis leak
1878            "Replace",    // ReturnPolicy-axis leak
1879            "Attested",   // ProcessPhase-axis leak
1880            "HoldFailed", // ReplacementPolicy-axis leak
1881        ] {
1882            let err = PoolPhase::from_str(bad).unwrap_err();
1883            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1884        }
1885    }
1886
1887    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1888    /// documented per-variant lifecycle role. Pinning this table at
1889    /// one site means any future status-aggregator surface
1890    /// (`feira pool list --healthy`, the SSE filter, the desired-loop
1891    /// heartbeat short-circuit) reads the same projection that the
1892    /// reconciler writes.
1893    #[test]
1894    fn pool_phase_predicate_truth_tables() {
1895        assert!(!PoolPhase::Initializing.is_steady());
1896        assert!(!PoolPhase::Initializing.is_terminal());
1897
1898        assert!(PoolPhase::Steady.is_steady());
1899        assert!(!PoolPhase::Steady.is_terminal());
1900
1901        assert!(!PoolPhase::ScalingUp.is_steady());
1902        assert!(!PoolPhase::ScalingUp.is_terminal());
1903
1904        assert!(!PoolPhase::ScalingDown.is_steady());
1905        assert!(!PoolPhase::ScalingDown.is_terminal());
1906
1907        assert!(!PoolPhase::Degraded.is_steady());
1908        assert!(!PoolPhase::Degraded.is_terminal());
1909
1910        assert!(!PoolPhase::Draining.is_steady());
1911        assert!(PoolPhase::Draining.is_terminal());
1912    }
1913
1914    /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1915    /// `is_steady` and `is_terminal` simultaneously — a draining pool
1916    /// is by definition transitioning OUT, not the goal converged
1917    /// state. A future variant that returned true from both would
1918    /// FAIL here, forcing the author to either pick one bucket or
1919    /// extend the consumer dispatch sites (status aggregators,
1920    /// heartbeat short-circuit) deliberately rather than silently
1921    /// double-firing both branches.
1922    #[test]
1923    fn pool_phase_steady_excludes_terminal() {
1924        for phase in PoolPhase::ALL {
1925            assert!(
1926                !(phase.is_steady() && phase.is_terminal()),
1927                "{phase:?} returns true from both is_steady and is_terminal — \
1928                 a draining pool is by definition not the converged goal state",
1929            );
1930        }
1931    }
1932
1933    /// COVERAGE CONTRACT: every variant lands somewhere — either the
1934    /// converged goal (`Steady`), the absorbing exit (`Draining`),
1935    /// or the transient bucket (`Initializing | ScalingUp |
1936    /// ScalingDown | Degraded` — pool is in motion toward desired).
1937    /// A future variant that returns `false` from BOTH predicates is
1938    /// fine *iff* it represents an in-motion state; this test pins
1939    /// the existing variants in their declared buckets so the
1940    /// projection consumers stay grounded.
1941    #[test]
1942    fn pool_phase_buckets_cover_every_variant() {
1943        let mut converged = 0u32;
1944        let mut terminal = 0u32;
1945        let mut transient = 0u32;
1946        for phase in PoolPhase::ALL {
1947            match (phase.is_steady(), phase.is_terminal()) {
1948                (true, false) => converged += 1,
1949                (false, true) => terminal += 1,
1950                (false, false) => transient += 1,
1951                (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
1952            }
1953        }
1954        assert_eq!(converged, 1, "converged bucket: Steady");
1955        assert_eq!(terminal, 1, "terminal bucket: Draining");
1956        assert_eq!(
1957            transient, 4,
1958            "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
1959        );
1960        assert_eq!(
1961            converged + terminal + transient,
1962            PoolPhase::ALL.len() as u32
1963        );
1964    }
1965
1966    /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
1967    /// variant a freshly-admitted pool should land in — `Initializing`
1968    /// — AND that variant is neither steady (no members yet) nor
1969    /// terminal (not deletion-stamped). A future `Default` rename
1970    /// without flipping the predicates fails here.
1971    #[test]
1972    fn pool_phase_default_is_initializing_in_transient_bucket() {
1973        let d = PoolPhase::default();
1974        assert_eq!(d, PoolPhase::Initializing);
1975        assert!(!d.is_steady());
1976        assert!(!d.is_terminal());
1977    }
1978
1979    // ─────────────────────────────────────────────────────────────────
1980    // `EphemeralPool::name_or_empty` — borrow-form metadata-projection
1981    // primitive on the `metadata.name` axis. Pins the missing-slot
1982    // corner, the populated-slot corner, the pre-lift chain-shape
1983    // parity, and the pure-projection discipline that the two
1984    // `tatara-pool-reconciler` consumers routed onto the primitive
1985    // depend on. See the primitive's doc-comment for the full
1986    // migration rationale.
1987    // ─────────────────────────────────────────────────────────────────
1988
1989    fn empty_template() -> EphemeralSpec {
1990        EphemeralSpec {
1991            aplicacao: crate::intent::AplicacaoIntent {
1992                chart_ref: "oci://x".into(),
1993                version: "1".into(),
1994                profile: String::new(),
1995                values_overlay: serde_json::Value::Null,
1996                release_name: None,
1997                target_namespace: None,
1998                install_timeout: None,
1999            },
2000            ttl: "1h".into(),
2001            teardown: crate::lifetime::TeardownPolicy::Always,
2002            max_concurrent: 0,
2003            postconditions: vec![],
2004            preconditions: vec![],
2005            verify_timeout: None,
2006            classification: None,
2007            parent: None,
2008            exports: vec![],
2009            routing: None,
2010        }
2011    }
2012
2013    fn pool_spec() -> PoolSpec {
2014        PoolSpec {
2015            desired_size: 1,
2016            min_size: 0,
2017            max_size: 0,
2018            return_policy: ReturnPolicy::Replace,
2019            selector: PoolSelector::default(),
2020            template: empty_template(),
2021            free_ttl: "24h".into(),
2022            max_allocation_ttl: "4h".into(),
2023            desired: 0,
2024            replacement_policy: ReplacementPolicy::default(),
2025            stable_name_claim: false,
2026        }
2027    }
2028
2029    fn pool_named(name: &str) -> EphemeralPool {
2030        EphemeralPool::new(name, pool_spec())
2031    }
2032
2033    fn pool_unnamed() -> EphemeralPool {
2034        let mut p = EphemeralPool::new("scratch", pool_spec());
2035        p.metadata.name = None;
2036        p
2037    }
2038
2039    #[test]
2040    fn name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2041        let p = pool_unnamed();
2042        assert!(p.metadata.name.is_none(), "fixture invariant");
2043        assert_eq!(p.name_or_empty(), "");
2044    }
2045
2046    #[test]
2047    fn name_or_empty_returns_populated_slot_verbatim() {
2048        let p = pool_named("attest-pool");
2049        assert_eq!(p.name_or_empty(), "attest-pool");
2050    }
2051
2052    #[test]
2053    fn name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2054        // Corner between `None` (missing slot) and `Some(String::new())`
2055        // (populated slot containing the empty string): the primitive
2056        // MUST fold both to the same `""` byte-shape so a downstream
2057        // `HashMap<String,_>::get(name)` / `str::cmp` sees ONE
2058        // "unnamed pool" bucket regardless of which shape the K8s API
2059        // server materialized. This is byte-identical to what the
2060        // pre-lift `.as_deref().unwrap_or("")` chain produced.
2061        let mut p = pool_named("scratch");
2062        p.metadata.name = Some(String::new());
2063        assert_eq!(p.name_or_empty(), "");
2064    }
2065
2066    #[test]
2067    fn name_or_empty_is_a_pure_projection() {
2068        // Consecutive calls return byte-identical slices — no cached
2069        // state, no mutation on the `EphemeralPool` between calls.
2070        // Guards against a future refactor that plants a cache field
2071        // and drifts one caller from another silently.
2072        let p = pool_named("router-pool");
2073        assert_eq!(p.name_or_empty(), p.name_or_empty());
2074        assert_eq!(p.name_or_empty(), "router-pool");
2075        assert_eq!(p.name_or_empty(), "router-pool");
2076    }
2077
2078    #[test]
2079    fn name_or_empty_matches_pre_lift_chain_verbatim() {
2080        // Byte-identical parity with the two hand-authored
2081        // `.metadata.name.as_deref().unwrap_or("")` chains the
2082        // primitive replaces in `tatara-pool-reconciler::router` and
2083        // `tatara-pool-reconciler::controller_allocation`. Runs across
2084        // the FULL corner set of the metadata.name slot: absent,
2085        // present-with-value, present-with-empty-string.
2086        let cases: [(Option<String>, &str); 3] = [
2087            (None, ""),
2088            (Some("attest-pool".into()), "attest-pool"),
2089            (Some(String::new()), ""),
2090        ];
2091        for (slot, expected) in cases {
2092            let mut p = pool_named("scratch");
2093            p.metadata.name = slot.clone();
2094            let pre_lift = p.metadata.name.as_deref().unwrap_or("");
2095            assert_eq!(pre_lift, expected, "pre-lift chain sanity");
2096            assert_eq!(p.name_or_empty(), pre_lift);
2097            assert_eq!(p.name_or_empty(), expected);
2098        }
2099    }
2100
2101    #[test]
2102    fn name_or_empty_borrows_from_metadata_name_slot() {
2103        // The returned `&str` is tied to the `EphemeralPool`'s
2104        // lifetime — the caller can compare / hash / index without
2105        // allocating. This is the load-bearing property that lets
2106        // the `HashMap<String, _>::get(pool.name_or_empty())` closure
2107        // in `controller_allocation::reconcile_inner` skip cloning.
2108        let p = pool_named("attest-pool");
2109        let s: &str = p.name_or_empty();
2110        assert_eq!(s.as_ptr(), p.metadata.name.as_deref().unwrap().as_ptr());
2111    }
2112
2113    // ─── EphemeralPool::owned_name_or_empty substrate pins ────────────
2114    //
2115    // The owned-form peer of the borrow-form `name_or_empty` primitive
2116    // above. Sibling to the sister-CRD primitive
2117    // `crate::crd::Process::owned_name_or_empty` (owned + empty sentinel
2118    // on `Process::metadata.name`) — the four primitives now partition
2119    // the (borrow × owned) × (name × uid) corner of the metadata-slot
2120    // family on identical missing-slot semantics across BOTH tatara-
2121    // process CRDs (`Process::uid_or_empty` + `Process::owned_name_or_empty`
2122    // + `EphemeralPool::name_or_empty` + this method). Fail-before-pass-
2123    // after granularity: `owned_name_or_empty` did not exist on the pool
2124    // CRD pre-lift; the compiler cannot resolve the name until the impl
2125    // block above is in place, so a rollback of the primitive breaks
2126    // this whole module.
2127    #[test]
2128    fn owned_name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2129        let p = pool_unnamed();
2130        assert!(p.metadata.name.is_none(), "fixture invariant");
2131        assert_eq!(p.owned_name_or_empty(), String::new());
2132    }
2133
2134    #[test]
2135    fn owned_name_or_empty_returns_owned_string_when_slot_is_populated() {
2136        let p = pool_named("attest-pool");
2137        assert_eq!(p.owned_name_or_empty(), "attest-pool");
2138    }
2139
2140    #[test]
2141    fn owned_name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2142        // Corner between `None` (missing slot) and `Some(String::new())`
2143        // (populated slot containing the empty string): the primitive
2144        // MUST fold both to the same `""` byte-shape so a downstream
2145        // `HashMap<String,_>::get(name)` sees ONE "unnamed pool" bucket
2146        // regardless of which shape the K8s API server materialized.
2147        // Byte-identical to what the pre-lift `.clone().unwrap_or_default()`
2148        // chain produced.
2149        let mut p = pool_named("scratch");
2150        p.metadata.name = Some(String::new());
2151        assert_eq!(p.owned_name_or_empty(), String::new());
2152        assert!(p.owned_name_or_empty().is_empty());
2153    }
2154
2155    #[test]
2156    fn owned_name_or_empty_is_a_pure_projection() {
2157        // Consecutive calls return byte-identical Strings — no cached
2158        // state, no mutation on the `EphemeralPool` between calls.
2159        // Guards against a future refactor that plants a cache field
2160        // and drifts one caller from another silently.
2161        let p = pool_named("router-pool");
2162        assert_eq!(p.owned_name_or_empty(), p.owned_name_or_empty());
2163        assert_eq!(p.owned_name_or_empty(), "router-pool");
2164        assert_eq!(p.owned_name_or_empty(), "router-pool");
2165    }
2166
2167    #[test]
2168    fn owned_name_or_empty_matches_pre_lift_chain_verbatim() {
2169        // Byte-identical parity with the two hand-authored
2170        // `.metadata.name.clone().unwrap_or_default()` chains the
2171        // primitive replaces in `tatara-pool-reconciler::
2172        // controller_allocation::reconcile_inner` (HashMap key seed)
2173        // and `tatara-pool-reconciler::allocation_decide::
2174        // AllocationConvergenceCtx::observe` (AllocationRef.name slot
2175        // seed). Runs across the FULL corner set of the metadata.name
2176        // slot: absent, present-with-value, present-with-empty-string.
2177        // A regression that inserted a normalization step at the
2178        // primitive the pre-lift chain does NOT apply — or vice versa —
2179        // surfaces here rather than as silent drift between the two
2180        // owned-form callsites and the ONE substrate owner they now
2181        // route through.
2182        let cases: [(Option<String>, &str); 3] = [
2183            (None, ""),
2184            (Some("attest-pool".into()), "attest-pool"),
2185            (Some(String::new()), ""),
2186        ];
2187        for (slot, expected) in cases {
2188            let mut p = pool_named("scratch");
2189            p.metadata.name = slot.clone();
2190            let pre_lift = p.metadata.name.clone().unwrap_or_default();
2191            assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2192            assert_eq!(p.owned_name_or_empty(), pre_lift);
2193            assert_eq!(p.owned_name_or_empty().as_str(), expected);
2194        }
2195    }
2196
2197    #[test]
2198    fn owned_name_or_empty_matches_borrow_form_peer_on_populated_slot() {
2199        // Cross-primitive coherence pin at the sibling corner: when the
2200        // slot is present, the borrow-form (`name_or_empty`) and owned-
2201        // form (`owned_name_or_empty`) primitives return the SAME byte
2202        // sequence and differ only in ownership. A regression that
2203        // skewed one form's fallback would surface here rather than as
2204        // silent drift between the router tie-break comparator and the
2205        // AllocationRef seed on the SAME pool.
2206        let p = pool_named("attest-pool");
2207        assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2208    }
2209
2210    #[test]
2211    fn owned_name_or_empty_matches_borrow_form_peer_on_missing_slot() {
2212        // Sibling corner of the coherence pin above: when the slot is
2213        // absent (or explicitly empty), BOTH primitives fold to the
2214        // same empty-string byte-shape. The load-bearing property is
2215        // that a caller who switches between the two return-forms
2216        // based on downstream ownership requirements never sees a
2217        // different missing-slot spelling as a side effect.
2218        let p = pool_unnamed();
2219        assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2220        assert_eq!(p.name_or_empty(), "");
2221        assert_eq!(p.owned_name_or_empty(), String::new());
2222    }
2223
2224    // ─── EphemeralPool::is_being_deleted substrate pins ───────────────
2225    //
2226    // Pins the copy-form metadata-projection primitive on the deletion-
2227    // tombstone axis of the pool CRD. Peer to the borrow-form + owned-
2228    // form metadata-fallback family (`name_or_empty`,
2229    // `owned_name_or_empty`); this one opens the presence-probe corner
2230    // for the tombstone slot. Sibling to the sister-CRD primitive
2231    // `crate::crd::Process::is_being_deleted` — the two primitives
2232    // now partition the tombstone-presence probe across BOTH tatara-
2233    // process CRDs on identical missing-slot semantics. Fail-before-
2234    // pass-after granularity: `is_being_deleted` did not exist on the
2235    // pool CRD pre-lift; the compiler cannot resolve the name until
2236    // the impl block above is in place, so a rollback of the primitive
2237    // breaks this whole module.
2238
2239    fn tombstoned_pool() -> EphemeralPool {
2240        let mut p = pool_named("attest-pool");
2241        p.metadata.namespace = Some("ephemeral-pools".into());
2242        p.metadata.deletion_timestamp = Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2243            Utc::now(),
2244        ));
2245        p
2246    }
2247
2248    #[test]
2249    fn is_being_deleted_returns_false_when_deletion_timestamp_is_absent() {
2250        // Missing-tombstone corner pin: the primitive collapses the
2251        // no-tombstone case to `false` so the `→ Drain` short-circuit
2252        // at `decide_pool_reconcile` is NOT taken and the observed-
2253        // phase composer at `pool_phase_from_members` proceeds to its
2254        // normal (free / spawning / allocated) arithmetic branches
2255        // instead of short-circuiting to `PoolPhase::Draining`.
2256        // Matches the pre-lift `.is_some()` chain's `false` byte-
2257        // identically at every consumer's downstream gate.
2258        let mut p = pool_named("attest-pool");
2259        p.metadata.deletion_timestamp = None;
2260        assert!(!p.is_being_deleted());
2261    }
2262
2263    #[test]
2264    fn is_being_deleted_returns_true_when_deletion_timestamp_is_present() {
2265        // Present-tombstone corner pin: the primitive returns `true`
2266        // on any populated `metadata.deletionTimestamp` slot regardless
2267        // of the timestamp payload — the two consumers only read the
2268        // tombstone's PRESENCE, never its RFC-3339 timestamp value.
2269        // A regression that gated the `true` return on the timestamp
2270        // being non-epoch, or parsed the timestamp before returning,
2271        // would surface here rather than as silent skew at the
2272        // `→ Drain` decision or the `→ Draining` phase report on the
2273        // SAME `EphemeralPool`.
2274        let p = tombstoned_pool();
2275        assert!(p.is_being_deleted());
2276    }
2277
2278    #[test]
2279    fn is_being_deleted_is_a_pure_projection() {
2280        // Purity pin: two consecutive calls return byte-identical
2281        // `bool` values (no lazy materialization, no interior
2282        // mutation of `self`). Peer to the sibling
2283        // `name_or_empty_is_a_pure_projection` +
2284        // `owned_name_or_empty_is_a_pure_projection` pins in this
2285        // module and to `is_being_deleted_is_a_pure_projection` on
2286        // the sister-CRD `Process`; all four bind the pure-projection
2287        // discipline on the ONE substrate accessor per metadata slot.
2288        let p = tombstoned_pool();
2289        let a = p.is_being_deleted();
2290        let b = p.is_being_deleted();
2291        assert_eq!(a, b);
2292        assert!(a);
2293    }
2294
2295    #[test]
2296    fn is_being_deleted_matches_pre_lift_pool_reconciler_chain_shape() {
2297        // Parity pin: sweeps the two corners every pre-lift consumer
2298        // plausibly encountered (missing tombstone, present tombstone)
2299        // and compares the substrate call against a hand-authored pre-
2300        // lift chain byte-identically. A regression that reshaped
2301        // either corner would surface here rather than as silent
2302        // operator-facing skew between the pool-reconciler's `→ Drain`
2303        // decision and the observed-phase composer's `→ Draining`
2304        // report on the SAME `EphemeralPool` within one reconcile
2305        // pass.
2306        fn pre_lift(p: &EphemeralPool) -> bool {
2307            p.metadata.deletion_timestamp.is_some()
2308        }
2309        // Missing slot.
2310        let mut p = pool_named("attest-pool");
2311        p.metadata.deletion_timestamp = None;
2312        assert_eq!(p.is_being_deleted(), pre_lift(&p));
2313        // Populated slot.
2314        let p = tombstoned_pool();
2315        assert_eq!(p.is_being_deleted(), pre_lift(&p));
2316    }
2317
2318    #[test]
2319    fn is_being_deleted_composes_with_pool_phase_draining_at_reconcile_preempt() {
2320        // Call-site-shape pin: the `pool_phase_from_members`
2321        // deletion-preempt returns `PoolPhase::Draining` as soon as
2322        // `pool.is_being_deleted()` holds, regardless of the (free +
2323        // spawning) supply arithmetic that would otherwise pick
2324        // `Ready` / `Scaling` / `Degraded`. The `→ Drain` decision at
2325        // `decide_pool_reconcile` composes with the same probe on the
2326        // same tombstone-presence slot. A regression that broadened
2327        // the tombstone probe implicitly (returning `false` on a
2328        // present but zero-timestamp) or narrowed it (requiring an
2329        // additional `.finalizers.is_empty()` conjunct that the two
2330        // consumers never spelled) would surface here rather than as
2331        // silent operator-facing skew between the pool reconciler's
2332        // decision and the observed-phase composer on the SAME
2333        // `EphemeralPool` within one reconcile pass.
2334        let alive = pool_named("attest-pool");
2335        assert!(!alive.is_being_deleted());
2336        let dying = tombstoned_pool();
2337        assert!(dying.is_being_deleted());
2338    }
2339
2340    // ─── EphemeralPool::owned_namespace_or_empty substrate pins ───────
2341    //
2342    // The owned-form peer of the `owned_name_or_empty` primitive on the
2343    // sibling `metadata.namespace` axis — the paired half of the
2344    // `AllocationRef { name, namespace }` struct literal both
2345    // `AllocationConvergenceCtx::observe` and the composition pin
2346    // consume through the SAME `AllocationRef::new(name, namespace)`
2347    // constructor. Fail-before-pass-after granularity:
2348    // `owned_namespace_or_empty` did not exist on the pool CRD pre-
2349    // lift; the compiler cannot resolve the name until the impl block
2350    // above is in place, so a rollback of the primitive breaks this
2351    // whole module.
2352    #[test]
2353    fn owned_namespace_or_empty_returns_empty_string_when_metadata_namespace_is_none() {
2354        // Missing-slot corner pin: the primitive collapses the no-
2355        // namespace case to the load-bearing empty-string sentinel so
2356        // the downstream `AllocationRef.namespace` slot carries `""`
2357        // rather than a defaulted `"default"` string. See the doc-
2358        // comment's DELIBERATE-EMPTY-SENTINEL rationale for why the
2359        // fallback matches `.clone().unwrap_or_default()` byte-for-
2360        // byte rather than substituting `Process::DEFAULT_NAMESPACE`
2361        // at the primitive.
2362        let mut p = pool_named("attest-pool");
2363        p.metadata.namespace = None;
2364        assert!(p.metadata.namespace.is_none(), "fixture invariant");
2365        assert_eq!(p.owned_namespace_or_empty(), String::new());
2366    }
2367
2368    #[test]
2369    fn owned_namespace_or_empty_returns_owned_string_when_slot_is_populated() {
2370        let mut p = pool_named("attest-pool");
2371        p.metadata.namespace = Some("ephemeral-pools".into());
2372        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2373    }
2374
2375    #[test]
2376    fn owned_namespace_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2377        // Corner between `None` (missing slot) and `Some(String::new())`
2378        // (populated slot containing the empty string): the primitive
2379        // MUST fold both to the same `""` byte-shape so a downstream
2380        // `AllocationRef.namespace ==` comparator at
2381        // `resolve_pool` sees ONE "unset namespace" bucket regardless
2382        // of which shape the K8s API server materialized. Byte-
2383        // identical to what the pre-lift `.clone().unwrap_or_default()`
2384        // chain produced.
2385        let mut p = pool_named("attest-pool");
2386        p.metadata.namespace = Some(String::new());
2387        assert_eq!(p.owned_namespace_or_empty(), String::new());
2388        assert!(p.owned_namespace_or_empty().is_empty());
2389    }
2390
2391    #[test]
2392    fn owned_namespace_or_empty_is_a_pure_projection() {
2393        // Consecutive calls return byte-identical Strings — no cached
2394        // state, no mutation on the `EphemeralPool` between calls.
2395        // Peer to the sibling `owned_name_or_empty_is_a_pure_projection`
2396        // pin in this module and to `is_being_deleted_is_a_pure_projection`
2397        // on the same CRD; all three bind the pure-projection
2398        // discipline on the ONE substrate accessor per metadata slot.
2399        let mut p = pool_named("attest-pool");
2400        p.metadata.namespace = Some("ephemeral-pools".into());
2401        assert_eq!(p.owned_namespace_or_empty(), p.owned_namespace_or_empty());
2402        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2403        assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2404    }
2405
2406    #[test]
2407    fn owned_namespace_or_empty_matches_pre_lift_chain_verbatim() {
2408        // Byte-identical parity with the two hand-authored
2409        // `.metadata.namespace.clone().unwrap_or_default()` chains
2410        // the primitive replaces in `tatara-pool-reconciler::
2411        // allocation_decide::AllocationConvergenceCtx::observe`
2412        // (matched-pool `AllocationRef.namespace` seed) and in the
2413        // sibling composition pin
2414        // `allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`.
2415        // Runs across the FULL corner set of the metadata.namespace
2416        // slot: absent, present-with-value, present-with-empty-string.
2417        // A regression that inserted a normalization step at the
2418        // primitive the pre-lift chain does NOT apply — or vice versa —
2419        // surfaces here rather than as silent drift between the two
2420        // owned-form callsites and the ONE substrate owner they now
2421        // route through.
2422        let cases: [(Option<String>, &str); 3] = [
2423            (None, ""),
2424            (Some("ephemeral-pools".into()), "ephemeral-pools"),
2425            (Some(String::new()), ""),
2426        ];
2427        for (slot, expected) in cases {
2428            let mut p = pool_named("attest-pool");
2429            p.metadata.namespace = slot.clone();
2430            let pre_lift = p.metadata.namespace.clone().unwrap_or_default();
2431            assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2432            assert_eq!(p.owned_namespace_or_empty(), pre_lift);
2433            assert_eq!(p.owned_namespace_or_empty().as_str(), expected);
2434        }
2435    }
2436
2437    #[test]
2438    fn owned_namespace_or_empty_composes_with_owned_name_or_empty_on_paired_slot_axis() {
2439        // Paired-axis coherence pin: the two owned-form primitives on
2440        // the pool CRD's `metadata.name` + `metadata.namespace` slots
2441        // share the SAME empty-string sentinel on the missing corner,
2442        // so a caller that composes both halves into an
2443        // `AllocationRef` (as `AllocationConvergenceCtx::observe`
2444        // does) never sees a mixed-fallback pair (one `""`, the
2445        // other `"default"`) as a side effect of one slot being
2446        // absent. A regression that skewed either primitive's
2447        // fallback would surface here rather than as silent operator-
2448        // facing skew between the paired halves of the SAME
2449        // `AllocationRef` seed.
2450        let mut p = pool_named("attest-pool");
2451        p.metadata.namespace = None;
2452        p.metadata.name = None;
2453        assert_eq!(p.owned_name_or_empty(), p.owned_namespace_or_empty());
2454        assert_eq!(p.owned_name_or_empty(), String::new());
2455        assert_eq!(p.owned_namespace_or_empty(), String::new());
2456    }
2457
2458    #[test]
2459    fn owned_namespace_or_empty_does_not_default_to_process_default_namespace() {
2460        // Deliberate-empty-sentinel pin: the primitive's fallback is
2461        // `""`, NOT `crate::crd::Process::DEFAULT_NAMESPACE`. The
2462        // sole downstream consumer (`AllocationConvergenceCtx::observe`)
2463        // feeds the produced value into `AllocationRef.namespace`,
2464        // which is then matched byte-identically against
2465        // `spec.pool_ref.namespace` at `resolve_pool`. A silent
2466        // substitution of `"default"` at this primitive would alias
2467        // every namespace-absent pool to the `"default"` bucket at
2468        // the matcher, hiding the missing-slot corner from an
2469        // operator who explicitly authored an allocation against a
2470        // namespace-unset pool. Pinned so a future "helpful"
2471        // canonicalization step lands as a compiler-visible failure
2472        // here rather than as silent operator-facing skew at the
2473        // matched-pool seed.
2474        let mut p = pool_named("attest-pool");
2475        p.metadata.namespace = None;
2476        assert_ne!(
2477            p.owned_namespace_or_empty(),
2478            crate::crd::Process::DEFAULT_NAMESPACE
2479        );
2480        assert_eq!(p.owned_namespace_or_empty(), "");
2481    }
2482
2483    // ─── AllocationRef::new substrate pins ────────────────────────────
2484    //
2485    // Pins the substrate constructor for [`AllocationRef`] — the
2486    // ONE-liner composer that lifts the paired
2487    // `AllocationRef { name, namespace }` struct-literal every
2488    // downstream consumer restated by hand pre-lift at FOUR production
2489    // sites (2 × controller_allocation.rs assignedProcess seeds, 1 ×
2490    // allocation_decide.rs pool_ref seed, 1 × allocation_factory.rs
2491    // pool_ref seed) onto ONE substrate owner on `AllocationRef`.
2492    // Fail-before-pass-after granularity: `AllocationRef::new` did not
2493    // exist pre-lift; the compiler cannot resolve the name until the
2494    // impl block above is in place, so a rollback of the primitive
2495    // breaks this whole module.
2496
2497    #[test]
2498    fn allocation_ref_new_composes_owned_string_pair_verbatim() {
2499        // Happy-path pin: the constructor materializes an
2500        // `AllocationRef { name: <name>, namespace: <namespace> }`
2501        // byte-identical to the pre-lift struct literal every consumer
2502        // spelled. A regression that dropped either slot (e.g. an
2503        // erroneous `..Default::default()` on a shape that never had
2504        // a Default derive) surfaces here rather than as silent slot
2505        // loss downstream at the assignedProcess / bound_pool /
2506        // matched_pool / spec.pool_ref sinks.
2507        let r = AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2508        assert_eq!(r.name, "pr-42-demo");
2509        assert_eq!(r.namespace, "ephemeral-pools");
2510    }
2511
2512    #[test]
2513    fn allocation_ref_new_matches_pre_lift_struct_literal_verbatim() {
2514        // Byte-identical parity pin: the substrate constructor and the
2515        // hand-authored struct literal produce equal `AllocationRef`
2516        // values on every provenance the FOUR pre-lift sites carried
2517        // (owned `String` from an owned-form projection; `&str`
2518        // promoted through `.to_string()`). A regression that inserted
2519        // a normalization step at the primitive the pre-lift literal
2520        // does NOT apply — or vice versa — surfaces here rather than
2521        // as silent drift between the four consumers and the ONE
2522        // substrate owner they now route through.
2523        let owned_name = String::from("pr-42-demo");
2524        let owned_ns = String::from("ephemeral-pools");
2525        let lifted = AllocationRef::new(owned_name.clone(), owned_ns.clone());
2526        let pre_lift = AllocationRef {
2527            name: owned_name,
2528            namespace: owned_ns,
2529        };
2530        assert_eq!(lifted, pre_lift);
2531    }
2532
2533    #[test]
2534    fn allocation_ref_new_accepts_str_provenance_via_into_string() {
2535        // `Into<String>` provenance-closure pin: the primitive accepts
2536        // every provenance the pre-lift sites carried. The
2537        // controller_allocation.rs assignedProcess seeds passed owned
2538        // `String` values (a moved `member_process_name` +
2539        // `ns.clone()`); the allocation_factory.rs pool_ref seed
2540        // passed `&str` (`n.to_string()` / `namespace.to_string()`).
2541        // Both provenances produce byte-identical output. A future
2542        // refactor of the constructor signature that demanded owned
2543        // `String` at author sites (dropping `impl Into<String>`)
2544        // would force `.to_string()` back at the FOUR call sites — the
2545        // pin fences that regression at ONE place.
2546        let from_str = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2547        let from_string =
2548            AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2549        assert_eq!(from_str, from_string);
2550        // Mixed provenance is also load-bearing: the allocation_decide.rs
2551        // matched_pool seed pairs an owned `String` (from
2552        // `EphemeralPool::owned_name_or_empty()`) with a hand-authored
2553        // `.clone().unwrap_or_default()` — also `String`. The
2554        // controller_allocation.rs paths pair a moved `String` name
2555        // with a `.clone()`-ed `ns: String`. Verify (owned, borrow)
2556        // and (borrow, owned) both compose to the same shape as
2557        // (owned, owned) / (borrow, borrow).
2558        let mixed_a = AllocationRef::new(String::from("pr-42-demo"), "ephemeral-pools");
2559        let mixed_b = AllocationRef::new("pr-42-demo", String::from("ephemeral-pools"));
2560        assert_eq!(from_str, mixed_a);
2561        assert_eq!(from_str, mixed_b);
2562    }
2563
2564    #[test]
2565    fn allocation_ref_new_positional_axis_order_pinned_name_first_namespace_second() {
2566        // Axis-order pin: name is the FIRST positional argument;
2567        // namespace is the SECOND. Reversing the pair at the
2568        // constructor is the exact regression this pin fences — the
2569        // FOUR pre-lift sites all spelled `name` before `namespace`
2570        // (matching the struct definition's field order in
2571        // `pub struct AllocationRef { pub name, pub namespace }`)
2572        // and the wire-format serde output `{ "name": "...",
2573        // "namespace": "..." }` reflects that order. A slot swap at
2574        // the primitive would surface here rather than as silent
2575        // `<namespace>/<name>` inversion at every downstream
2576        // qualified-ref composer that reads `{ref.name}/{ref.namespace}`
2577        // as an audit-log key.
2578        let r = AllocationRef::new("alpha-name", "beta-namespace");
2579        assert_eq!(r.name, "alpha-name");
2580        assert_eq!(r.namespace, "beta-namespace");
2581        assert_ne!(r.name, "beta-namespace");
2582        assert_ne!(r.namespace, "alpha-name");
2583    }
2584
2585    #[test]
2586    fn allocation_ref_new_preserves_empty_string_verbatim() {
2587        // Empty-string sentinel pin: the constructor is pure — it does
2588        // NOT canonicalize empty inputs (does NOT default an empty
2589        // namespace to `"default"`; does NOT reject an empty name).
2590        // Preserves the pre-lift shape the allocation_decide.rs
2591        // matched_pool seed relied on: when the pool's metadata.namespace
2592        // is absent, `.clone().unwrap_or_default()` yields the empty
2593        // string, and the AllocationRef's namespace slot carries that
2594        // empty string verbatim to the downstream `bound_pool` sink.
2595        // A future canonicalization pass (e.g. defaulting to
2596        // `Process::DEFAULT_NAMESPACE`) MUST land here, not at the
2597        // primitive body silently, so the pre-lift consumers' empty-
2598        // sentinel semantics are the visible contract of the new
2599        // constructor.
2600        let r = AllocationRef::new("", "");
2601        assert_eq!(r.name, "");
2602        assert_eq!(r.namespace, "");
2603        let mixed = AllocationRef::new("pr-42-demo", "");
2604        assert_eq!(mixed.name, "pr-42-demo");
2605        assert_eq!(mixed.namespace, "");
2606    }
2607
2608    #[test]
2609    fn allocation_ref_new_composes_with_owned_name_or_empty_pool_projection() {
2610        // Composition pin: the constructor composes with the paired
2611        // substrate primitives [`EphemeralPool::owned_name_or_empty`]
2612        // + [`EphemeralPool::owned_namespace_or_empty`] at the
2613        // allocation_decide.rs pool_ref seed — the same primitive
2614        // family the pool CRD opened for both halves of the
2615        // `AllocationRef { name, namespace }` struct literal. The
2616        // composed pair carries an owned `String` name half (from
2617        // `pool.owned_name_or_empty()`) and an owned `String`
2618        // namespace half (from `pool.owned_namespace_or_empty()`) —
2619        // no pre-lift chain remains. A regression that broke the
2620        // primitive family's `impl Into<String>` acceptance of an
2621        // owned `String` return type would surface here rather than
2622        // as silent build failure at the pool-reconciler matched_pool
2623        // seed.
2624        let pool = pool_named("attest-pool");
2625        let r = AllocationRef::new(pool.owned_name_or_empty(), pool.owned_namespace_or_empty());
2626        assert_eq!(r.name, "attest-pool");
2627        assert_eq!(r.namespace, pool.owned_namespace_or_empty());
2628    }
2629
2630    #[test]
2631    fn allocation_ref_new_returns_wire_format_serialization_verbatim() {
2632        // Wire-format pin: the constructor produces an
2633        // [`AllocationRef`] whose serde `rename_all = "camelCase"`
2634        // serialization is byte-identical to the pre-lift struct
2635        // literal's serialization. The `bound_pool` and
2636        // `assignedProcess` slots on `AllocationStatus` (and the
2637        // `poolRef` slot on `AllocationSpec`) all round-trip through
2638        // this shape — the pin fences a regression that added a
2639        // private field or a `#[serde(skip)]` accidentally.
2640        let r = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2641        let yaml = serde_yaml::to_string(&r).expect("AllocationRef serializes to yaml");
2642        assert!(yaml.contains("name: pr-42-demo"), "{yaml}");
2643        assert!(yaml.contains("namespace: ephemeral-pools"), "{yaml}");
2644        let back: AllocationRef =
2645            serde_yaml::from_str(&yaml).expect("AllocationRef round-trips through yaml");
2646        assert_eq!(back, r);
2647    }
2648}