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
703impl PoolStatus {
704 /// Substrate constructor for the observed [`PoolStatus`] seed:
705 /// composes the `(phase, phase_since, ready/allocated/spawning
706 /// /returning counts, members, message, conditions)` 9-slot record
707 /// every pool-reconciler status-patch site restated by hand pre-
708 /// lift. The four counters ride a SINGLE closed-set-driven fold
709 /// over the members list (one pass rather than four independent
710 /// filter-and-count passes); the `message` + `conditions` slots
711 /// stay at their invariant `None` / `vec![]` defaults every pre-
712 /// lift caller stamped verbatim, and `phase_since` is derived from
713 /// the caller-supplied `now` timestamp so the constructor stays
714 /// clock-injectable rather than implicitly reading wall time.
715 ///
716 /// Pre-lift the 11-line
717 /// ```rust,ignore
718 /// PoolStatus {
719 /// phase,
720 /// phase_since: Some(Utc::now()),
721 /// ready_count: count_state(&members, MemberState::Free),
722 /// allocated_count: count_state(&members, MemberState::Allocated),
723 /// spawning_count: count_state(&members, MemberState::Spawning),
724 /// returning_count: count_state(&members, MemberState::Returning),
725 /// members: members.clone(),
726 /// message: None,
727 /// conditions: vec![],
728 /// }
729 /// ```
730 /// incantation was hand-authored at TWO sites past the ★★ PRIME-
731 /// DIRECTIVE ≥ 2 duplication threshold in
732 /// `tatara-pool-reconciler::controller_pool::reconcile_inner`,
733 /// both restating the same 4-slot count fanout + defaults:
734 /// * The `desired > 0` path — status patch after the
735 /// convergence-action loop when the operator drives the pool
736 /// through the R11 desired-count invariant.
737 /// * The legacy allocation-driven path (`desired == 0`) — status
738 /// patch after the [`crate::pool::PoolDecision`] apply loop.
739 ///
740 /// Both sites walked the SAME 4-slot count fanout on the SAME
741 /// four `MemberState` variants (Free/Allocated/Spawning/Returning)
742 /// and stamped the SAME defaults (`message: None`, `conditions:
743 /// vec![]`), even though the four counters walked the members list
744 /// four independent times pre-lift when a single pass suffices.
745 /// Post-lift both callers write
746 /// `PoolStatus::observed(phase, members, Utc::now())` and share
747 /// ONE substrate owner; a future counter slot (e.g., a
748 /// `warming_count` for a `MemberState::Warming` variant between
749 /// Spawning and Free) plugs into the fold at ONE match arm and
750 /// both status-patch sites inherit the new slot mechanically.
751 ///
752 /// The `Failed` variant is deliberately absent from the fold — no
753 /// `PoolStatus` slot counts failed members (they surface via
754 /// `pool_phase_from_members`'s `PoolPhase::Degraded` transition
755 /// instead), and the closed-set match on
756 /// [`MemberState`] pins that a future variant which SHOULD count
757 /// toward one of the four buckets triggers the compiler's
758 /// exhaustiveness check at this fold rather than silently sinking
759 /// into `Failed`'s no-op arm.
760 ///
761 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
762 /// the 11-line status-seed incantation recurred at two hand-
763 /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
764 /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
765 /// invariant 5 (composition preserves proofs — the pins bind the
766 /// 4-slot count fanout + the closed-set exhaustiveness on
767 /// `MemberState` + the invariant defaults, so a regression that
768 /// dropped a counter slot or swapped a variant surfaces at
769 /// `tests::pool_status_observed_*` rather than as silent operator-
770 /// facing skew between the two status-patch sites on the SAME
771 /// pool).
772 #[must_use]
773 pub fn observed(phase: PoolPhase, members: Vec<PoolMember>, now: DateTime<Utc>) -> Self {
774 let (ready_count, allocated_count, spawning_count, returning_count) =
775 PoolMember::state_count_fanout(&members);
776 Self {
777 phase,
778 phase_since: Some(now),
779 ready_count,
780 allocated_count,
781 spawning_count,
782 returning_count,
783 members,
784 message: None,
785 conditions: vec![],
786 }
787 }
788}
789
790impl PoolMember {
791 /// Substrate primitive: single-pass closed-set fold over a
792 /// `[PoolMember]` slice producing the `(ready, allocated,
793 /// spawning, returning)` 4-tuple every `PoolStatus` seed stamps at
794 /// its four counter slots. The `Failed` arm is a no-op (no
795 /// `PoolStatus` counter tracks failed members — they surface via
796 /// [`PoolPhase::Degraded`] instead), pinned by the closed-set
797 /// match so a future variant that SHOULD count toward one of the
798 /// four buckets triggers the compiler's exhaustiveness check here
799 /// rather than silently falling through.
800 ///
801 /// Consumed by [`PoolStatus::observed`]. A caller that needs a
802 /// single per-variant count outside the status-seed fanout should
803 /// keep spelling `members.iter().filter(...).count()` rather than
804 /// walking this 4-tuple — the fanout is shaped for the
805 /// `PoolStatus` fill, not for arbitrary per-variant queries.
806 #[must_use]
807 pub fn state_count_fanout(members: &[Self]) -> (u32, u32, u32, u32) {
808 let mut ready = 0u32;
809 let mut allocated = 0u32;
810 let mut spawning = 0u32;
811 let mut returning = 0u32;
812 for m in members {
813 match m.state {
814 MemberState::Free => ready += 1,
815 MemberState::Allocated => allocated += 1,
816 MemberState::Spawning => spawning += 1,
817 MemberState::Returning => returning += 1,
818 MemberState::Failed => {}
819 }
820 }
821 (ready, allocated, spawning, returning)
822 }
823}
824
825/// Light reference to an `EphemeralAllocation`.
826#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
827#[serde(rename_all = "camelCase")]
828pub struct AllocationRef {
829 pub name: String,
830 pub namespace: String,
831}
832
833impl AllocationRef {
834 /// Substrate constructor for [`AllocationRef`]: composes the
835 /// `(name, namespace)` pair through ONE `impl Into<String>`-gated
836 /// entry point — the ONE-liner collapse of the paired
837 /// `AllocationRef { name: n.into(), namespace: ns.into() }`
838 /// struct-literal incantation every downstream consumer restated
839 /// by hand pre-lift.
840 ///
841 /// Pre-lift the `AllocationRef { name, namespace }` struct-literal
842 /// was hand-authored at FOUR production sites past the ★★ PRIME-
843 /// DIRECTIVE ≥ 2 duplication threshold across the workspace, all
844 /// composing an owned `(name: String, namespace: String)` pair
845 /// under one of two roles:
846 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
847 /// Bind path — the `assignedProcess` status slot's ref, pairing
848 /// the just-bound member Process name with the allocation's
849 /// containing namespace.
850 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
851 /// Release path — the same `assignedProcess` slot shape, stamped
852 /// at the release-side status patch alongside the (unchanged)
853 /// `boundPool` ref.
854 /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`
855 /// pool-matched handle — the `matched_pool` slot's ref, pairing
856 /// [`EphemeralPool::owned_name_or_empty`] with the pool's
857 /// containing namespace.
858 /// * `tatara-github-watcher::allocation_factory::allocation_from_pr`
859 /// — the `pool_ref` slot on the `AllocationSpec` emitted from a
860 /// PullRequestEvent, pairing the operator-configured pool name
861 /// with the watcher's target namespace.
862 ///
863 /// All FOUR sites walked the SAME two-field struct-literal shape
864 /// — an owned name half, an owned namespace half — differing only
865 /// in provenance. Post-lift each callsite reads
866 /// `AllocationRef::new(name, ns)` and the produced value feeds the
867 /// same downstream slot (`assignedProcess` / `bound_pool` /
868 /// `matched_pool` / `spec.pool_ref`) unchanged. The `impl Into<String>`
869 /// signature accepts every provenance the pre-lift sites carried —
870 /// owned `String` (the reconciler's owned-form projections), `&str`
871 /// (the factory's `n.to_string()` / `namespace.to_string()`
872 /// borrow-to-owned promotions), `Cow<str>`, and every other
873 /// `Into<String>` implementor — so no callsite has to change its
874 /// upstream provenance to route through the primitive.
875 ///
876 /// Return-form axis: owned [`AllocationRef`] — the wire-format
877 /// shape [`crate::pool::AllocationRef`]'s serde `rename_all =
878 /// "camelCase"` produces on both spec (`poolRef`) and status
879 /// (`boundPool` / `assignedProcess`) slots. The primitive owns
880 /// the axis-order `(name, namespace)` — the same order the four
881 /// consumers spelled — so a slot swap surfaces at the
882 /// `allocation_ref_new_positional_axis_order` pin below rather
883 /// than as silent `<namespace>/<name>` inversion downstream.
884 ///
885 /// Peer to the sibling substrate primitives already opened on the
886 /// pool-side (name, namespace) axis pair:
887 /// [`EphemeralPool::name_or_empty`] (borrow-form name),
888 /// [`EphemeralPool::owned_name_or_empty`] (owned-form name); this
889 /// constructor is the composer that folds the owned-form projections
890 /// into the wire-format ref shape.
891 ///
892 /// A future refactor of [`AllocationRef`]'s field set (a
893 /// `resource_kind: String` field for cross-CRD refs, an
894 /// `api_version: String` field for FQN references, a
895 /// canonicalization pass over the namespace half, a non-empty-name
896 /// gate) lands at ONE substrate constructor site here and every
897 /// downstream consumer inherits the upgrade mechanically — no per-
898 /// callsite hand-edit at the FOUR reconciler + factory sites.
899 ///
900 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
901 /// the `AllocationRef { name, namespace }` struct-literal shape
902 /// recurred at four hand-authored sites past the ★★ PRIME-
903 /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
904 /// here). THEORY.md §II.1 invariant 5 (composition preserves
905 /// proofs — the pins bind the positional axis-order + the
906 /// `Into<String>` provenance closure + byte-identical parity with
907 /// the pre-lift struct-literal + `PartialEq` coherence with the
908 /// hand-authored form, so a regression that reshaped any surface
909 /// at `tests::allocation_ref_new_*` rather than as silent
910 /// operator-facing skew between the assignedProcess / bound_pool
911 /// / matched_pool / spec.pool_ref slots on the SAME allocation).
912 #[must_use]
913 pub fn new(name: impl Into<String>, namespace: impl Into<String>) -> Self {
914 Self {
915 name: name.into(),
916 namespace: namespace.into(),
917 }
918 }
919}
920
921/// Per-slot state in the pool's free list.
922///
923/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
924/// (the on-failure policy that the pool reconciler dispatches against
925/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
926/// release-time disposition that transitions an [`Self::Allocated`]
927/// member into [`Self::Returning`] before it either re-enters
928/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
929#[derive(
930 Clone,
931 Copy,
932 Debug,
933 PartialEq,
934 Eq,
935 Hash,
936 Serialize,
937 Deserialize,
938 JsonSchema,
939 tatara_closed_set::DeriveClosedSet,
940)]
941#[serde(rename_all = "PascalCase")]
942#[closed_set(via = "as_str", generate_unknown, display)]
943pub enum MemberState {
944 /// Pool reconciler is creating/converging the backing Process.
945 Spawning,
946 /// Process is `Attested`; ready for allocation.
947 Free,
948 /// Held by an `EphemeralAllocation`.
949 Allocated,
950 /// Return policy is being applied (Reset → reset Job; Replace →
951 /// Process is being torn down and recreated).
952 Returning,
953 /// Permanent failure — the member needs operator attention.
954 Failed,
955}
956
957impl MemberState {
958 /// The closed set of member states — single source of truth that
959 /// drives the `as_str` / Display / `FromStr` triad AND the
960 /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
961 /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
962 /// arm per predicate — exhaustively checked by the compiler (the
963 /// `[Self; 5]` array literal forces the arity) and by the
964 /// per-variant truth-table contract test (a new variant must
965 /// declare its own `(is_failed, counts_toward_supply)` projection
966 /// or the consumer dispatch in
967 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
968 /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
969 /// will silently bucket it into the wrong lifecycle column).
970 pub const ALL: [Self; 5] = [
971 Self::Spawning,
972 Self::Free,
973 Self::Allocated,
974 Self::Returning,
975 Self::Failed,
976 ];
977
978 /// Canonical PascalCase wire-format projection — matches the serde
979 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
980 /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
981 /// `status.members[].state`. Pinned by
982 /// `member_state_as_str_matches_serde` so a variant rename can't
983 /// drift between the typed surface, the CRD enum, the YAML wire
984 /// format AND any future operator-facing diagnostic that composes
985 /// `state={state}` via Display rather than a hard-coded literal
986 /// that would silently rot.
987 pub const fn as_str(self) -> &'static str {
988 match self {
989 Self::Spawning => "Spawning",
990 Self::Free => "Free",
991 Self::Allocated => "Allocated",
992 Self::Returning => "Returning",
993 Self::Failed => "Failed",
994 }
995 }
996
997 /// Is this member in a permanent-failure state — needs operator
998 /// attention? Closed-set match (not `matches!`) so a future variant
999 /// triggers the compiler's exhaustiveness check at this site rather
1000 /// than silently defaulting to `false`. Consumed by
1001 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
1002 /// gate the highest-priority `ReplaceMembers` decision branch — a
1003 /// future variant that should also trigger replacement (e.g.
1004 /// `MemberState::Quarantined`) flips this predicate at one site
1005 /// and inherits the priority-1 dispatch without touching the
1006 /// consumer match arm.
1007 pub const fn is_failed(self) -> bool {
1008 match self {
1009 Self::Failed => true,
1010 Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
1011 }
1012 }
1013
1014 /// Does this member contribute to the pool's *available supply*
1015 /// (current ready slots + slots coming online)? Closed-set match so
1016 /// a future variant triggers the compiler's exhaustiveness check.
1017 /// Consumed by
1018 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1019 /// — the `(free + spawning)` supply calc collapses into one
1020 /// predicate-driven filter, so a future "warming-up" state
1021 /// (`MemberState::Warming` between Spawning and Free) plugs into
1022 /// the supply count at one site rather than three. Disjoint with
1023 /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
1024 /// (a Failed member can never count toward supply; the pool
1025 /// reconciler would otherwise double-count failures as available
1026 /// capacity).
1027 pub const fn counts_toward_supply(self) -> bool {
1028 match self {
1029 Self::Free | Self::Spawning => true,
1030 Self::Allocated | Self::Returning | Self::Failed => false,
1031 }
1032 }
1033}
1034
1035// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
1036// MemberState` + `impl fmt::Display for MemberState` are generated by
1037// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1038// above. `label` delegates to the inherent `MemberState::as_str` via
1039// `#[closed_set(via = "as_str")]` so the
1040// `pool_phase_from_members` supply calc can keep keying on
1041// `counts_toward_supply` against the typed variant while a generic
1042// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
1043// (`label`) without knowing this enum lives in `tatara-process::pool`;
1044// Display delegates to the same inherent projection via
1045// `#[closed_set(display)]` so the diagnostic emitter's
1046// `state={state}` composition stays pinned on the closed-set algebra.
1047
1048// `pub struct UnknownMemberState(pub String)` is generated by
1049// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1050// on the enum declaration above. The auto-derived label `"member state"`
1051// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
1052// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1053// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
1054// [`crate::lifetime::UnknownTeardownPolicy`],
1055// [`crate::boundary::UnknownConditionKind`], and
1056// [`crate::phase::UnknownPhase`].
1057
1058/// Pool lifecycle phase (observed across the whole pool population).
1059///
1060/// Sibling closed-set on the same `EphemeralPool` axis as
1061/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
1062/// over via [`MemberState::counts_toward_supply`]),
1063/// [`ReplacementPolicy::ALL`] (on-failure policy) and
1064/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
1065/// `MemberState`, this closes the pool reconciler's
1066/// `(slot-state, pool-phase)` two-tier observation algebra on the
1067/// same closed-set discipline as the rest of `tatara-process`.
1068#[derive(
1069 Clone,
1070 Copy,
1071 Debug,
1072 PartialEq,
1073 Eq,
1074 Hash,
1075 Serialize,
1076 Deserialize,
1077 JsonSchema,
1078 tatara_closed_set::DeriveClosedSet,
1079)]
1080#[serde(rename_all = "PascalCase")]
1081#[closed_set(via = "as_str", generate_unknown, display)]
1082pub enum PoolPhase {
1083 /// Just admitted; no members yet.
1084 Initializing,
1085 /// `ready_count == desired_size`.
1086 Steady,
1087 /// `ready_count + spawning_count < desired_size` and reconciler
1088 /// is creating new members.
1089 ScalingUp,
1090 /// `ready_count > desired_size` and reconciler is reaping excess.
1091 ScalingDown,
1092 /// `min_size` constraint violated.
1093 Degraded,
1094 /// Pool is being deleted; reconciler is reaping all members.
1095 Draining,
1096}
1097
1098impl Default for PoolPhase {
1099 fn default() -> Self {
1100 Self::Initializing
1101 }
1102}
1103
1104impl PoolPhase {
1105 /// The closed set of pool phases — single source of truth that
1106 /// drives the `as_str` / Display / `FromStr` triad AND the
1107 /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
1108 /// variant lands at one `ALL` entry + one `as_str` arm + one arm
1109 /// per predicate — exhaustively checked by the compiler (the
1110 /// `[Self; 6]` array literal forces the arity) AND by the
1111 /// per-variant truth-table contract test (a new variant must
1112 /// declare its own `(is_steady, is_terminal)` projection or any
1113 /// future status-aggregator surface — `feira pool list
1114 /// --healthy`, the operator-facing condition aggregator, the
1115 /// desired-loop heartbeat short-circuit — will silently bucket
1116 /// it into the wrong lifecycle column).
1117 pub const ALL: [Self; 6] = [
1118 Self::Initializing,
1119 Self::Steady,
1120 Self::ScalingUp,
1121 Self::ScalingDown,
1122 Self::Degraded,
1123 Self::Draining,
1124 ];
1125
1126 /// Canonical PascalCase wire-format projection — matches the
1127 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1128 /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
1129 /// stamps on `status.phase`. Pinned by
1130 /// `pool_phase_as_str_matches_serde` so a variant rename can't
1131 /// drift between the typed surface, the CRD enum, the YAML wire
1132 /// format AND any future operator-facing diagnostic that
1133 /// composes `phase={phase}` via Display rather than a hard-coded
1134 /// literal that would silently rot. Display + FromStr triad
1135 /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
1136 /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
1137 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
1138 pub const fn as_str(self) -> &'static str {
1139 match self {
1140 Self::Initializing => "Initializing",
1141 Self::Steady => "Steady",
1142 Self::ScalingUp => "ScalingUp",
1143 Self::ScalingDown => "ScalingDown",
1144 Self::Degraded => "Degraded",
1145 Self::Draining => "Draining",
1146 }
1147 }
1148
1149 /// Is the pool fully converged — supply matches desired, no
1150 /// reconciler-driven population change pending? Closed-set match
1151 /// (not `matches!`) so a future variant triggers the compiler's
1152 /// exhaustiveness check at this site rather than silently
1153 /// defaulting to `false`. Paired with `is_terminal` they form
1154 /// the two-axis projection that future status aggregators
1155 /// (operator-facing fleet health, `feira pool list --healthy`,
1156 /// the SSE filter "show non-steady pools") dispatch against —
1157 /// `is_steady && !is_terminal` ⇒ converged (goal state);
1158 /// `!is_steady && is_terminal` ⇒ being deleted (no future
1159 /// spawn); `!is_steady && !is_terminal` ⇒ transient
1160 /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
1161 /// is in motion toward desired). The impossible bucket
1162 /// `(true, true)` — a draining pool that's somehow also steady
1163 /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
1164 pub const fn is_steady(self) -> bool {
1165 match self {
1166 Self::Steady => true,
1167 Self::Initializing
1168 | Self::ScalingUp
1169 | Self::ScalingDown
1170 | Self::Degraded
1171 | Self::Draining => false,
1172 }
1173 }
1174
1175 /// Is the pool in its absorbing exit state — deletion-stamped,
1176 /// reconciler is reaping every member, no spawn will ever
1177 /// happen again? Closed-set match so a future variant triggers
1178 /// the compiler's exhaustiveness check. See `is_steady` for the
1179 /// predicate-pair contract + bucket definitions.
1180 pub const fn is_terminal(self) -> bool {
1181 match self {
1182 Self::Draining => true,
1183 Self::Initializing
1184 | Self::Steady
1185 | Self::ScalingUp
1186 | Self::ScalingDown
1187 | Self::Degraded => false,
1188 }
1189 }
1190}
1191
1192// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
1193// + `impl fmt::Display for PoolPhase` are generated by
1194// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
1195// `label` delegates to the inherent `PoolPhase::as_str` via
1196// `#[closed_set(via = "as_str")]` so the operator-facing
1197// `phase={phase}` Display composition keeps reading the same canonical
1198// PascalCase projection while a generic `T: ClosedSet` consumer (a
1199// status-aggregator filter, the `feira pool list --healthy` predicate, a
1200// future SSE event router) can walk every variant without knowing the
1201// closed set lives in `tatara-process::pool`; Display delegates to the
1202// same inherent projection via `#[closed_set(display)]` so the
1203// `phase={phase}` composition stays pinned on the closed-set algebra
1204// rather than a hand-rolled `fmt::Display` block.
1205
1206// `pub struct UnknownPoolPhase(pub String)` is generated by
1207// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1208// on the enum declaration above. The auto-derived label `"pool phase"`
1209// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
1210// verbatim. Symmetric to [`UnknownMemberState`],
1211// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
1212// [`crate::lifetime::UnknownTeardownPolicy`],
1213// [`crate::boundary::UnknownConditionKind`], and
1214// [`crate::phase::UnknownPhase`].
1215
1216/// Standard K8s Condition shape (kept local so tatara-process doesn't
1217/// depend on k8s_openapi types in its public schema).
1218#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
1219#[serde(rename_all = "camelCase")]
1220pub struct PoolCondition {
1221 pub type_: String,
1222 pub status: String,
1223 pub reason: String,
1224 pub message: String,
1225 pub last_transition_time: DateTime<Utc>,
1226}
1227
1228/// What the pool does when an allocation releases a member.
1229///
1230/// Sibling closed-set on the `EphemeralPool` axis:
1231/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
1232/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
1233/// (the *release*-time counterpart for non-pooled ephemeral envs),
1234/// [`crate::boundary::ConditionKind::ALL`],
1235/// [`crate::lifetime::LifetimeKind::ALL`],
1236/// [`crate::intent::IntentKind::ALL`],
1237/// [`crate::phase::ProcessPhase::ALL`],
1238/// [`crate::signal::ProcessSignal::ALL`].
1239#[derive(
1240 Clone,
1241 Copy,
1242 Debug,
1243 Hash,
1244 PartialEq,
1245 Eq,
1246 Serialize,
1247 Deserialize,
1248 JsonSchema,
1249 Default,
1250 tatara_closed_set::DeriveClosedSet,
1251)]
1252#[serde(rename_all = "PascalCase")]
1253#[closed_set(via = "as_str", generate_unknown, display)]
1254pub enum ReturnPolicy {
1255 /// Tear down the Process + create a fresh one. Safe but slow
1256 /// (1-2 min spin-up before the slot is Free again).
1257 #[default]
1258 Replace,
1259 /// Keep the Process running; run a typed `:reset` Job that wipes
1260 /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
1261 /// the reset Job being correct for the workload. API-authoritative
1262 /// systems are natural fits because the control API owns all state.
1263 Reset,
1264 /// Keep the Process indefinitely after release (debugging aid;
1265 /// operator must `feira pool reap NAME` to clean up). Useful for
1266 /// post-mortem of a flaky test.
1267 Keep,
1268}
1269
1270impl ReturnPolicy {
1271 /// The closed set of return policies — single source of truth that
1272 /// drives the `as_str` / Display / `FromStr` triad and the
1273 /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
1274 /// fourth variant lands at one `ALL` entry + one `as_str` arm +
1275 /// one arm per predicate — exhaustively checked by the compiler
1276 /// (the `[Self; 3]` array literal forces the arity) and by the
1277 /// predicate-pair injectivity test (a new variant must land in
1278 /// its own (keeps_process, runs_reset_job) bucket or the author
1279 /// has to extend the consumer dispatch in
1280 /// `tatara-pool-reconciler::return_policy::plan_return`).
1281 pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
1282
1283 /// Canonical PascalCase wire-format projection — matches the
1284 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1285 /// `enum:` enumeration the pool reconciler stamps on the
1286 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
1287 /// `return_policy_as_str_matches_serde` so a variant rename can't
1288 /// drift between the typed surface, the CRD enum, the YAML wire
1289 /// format AND any future operator-facing diagnostic that composes
1290 /// `policy={policy}` via Display rather than a hard-coded literal.
1291 pub const fn as_str(self) -> &'static str {
1292 match self {
1293 Self::Replace => "Replace",
1294 Self::Reset => "Reset",
1295 Self::Keep => "Keep",
1296 }
1297 }
1298
1299 /// Does the pool keep the backing Process alive across release?
1300 /// Closed-set match (not `matches!`) so a future variant triggers
1301 /// the compiler's exhaustiveness check at this site rather than
1302 /// silently defaulting to `false`. Paired with `runs_reset_job`
1303 /// they form the two-axis projection that the consumer in
1304 /// `tatara-pool-reconciler::return_policy::plan_return` matches
1305 /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
1306 /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
1307 /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
1308 /// pair is `(false, false) | (true, true) | (true, false)` —
1309 /// pinned injective by
1310 /// `return_policy_predicate_pair_is_injective`.
1311 pub const fn keeps_process(self) -> bool {
1312 match self {
1313 Self::Replace => false,
1314 Self::Reset | Self::Keep => true,
1315 }
1316 }
1317
1318 /// Does the policy run a typed `:reset` Job to wipe state in
1319 /// place? See `keeps_process` for the closed-match rationale +
1320 /// the predicate-pair contract.
1321 pub const fn runs_reset_job(self) -> bool {
1322 match self {
1323 Self::Reset => true,
1324 Self::Replace | Self::Keep => false,
1325 }
1326 }
1327}
1328
1329// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
1330// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
1331// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1332// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
1333// `#[closed_set(via = "as_str")]` so the
1334// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
1335// reading the canonical PascalCase projection that matches the CRD
1336// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
1337// plugs in without knowing the enum lives in `tatara-process::pool`;
1338// Display delegates to the same inherent projection via
1339// `#[closed_set(display)]` so the `policy={policy}` diagnostic
1340// composition stays pinned on the closed-set algebra.
1341
1342// `pub struct UnknownReturnPolicy(pub String)` is generated by
1343// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1344// on the enum declaration above. The auto-derived label `"return policy"`
1345// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
1346// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1347// [`UnknownMemberState`], [`UnknownPoolPhase`],
1348// [`crate::lifetime::UnknownTeardownPolicy`],
1349// [`crate::boundary::UnknownConditionKind`], and
1350// [`crate::phase::UnknownPhase`].
1351
1352/// Routing selector — matches an `EphemeralAllocation`'s requestor
1353/// against pool-eligibility predicates.
1354#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1355#[serde(rename_all = "camelCase")]
1356pub struct PoolSelector {
1357 /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
1358 /// Empty = match every repo.
1359 #[serde(default)]
1360 pub repos: Vec<String>,
1361
1362 /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
1363 /// Empty = match every branch.
1364 #[serde(default)]
1365 pub branches: Vec<String>,
1366
1367 /// PR labels (all-must-match, AND semantics). Empty = no label
1368 /// requirement.
1369 #[serde(default)]
1370 pub pr_labels: Vec<String>,
1371
1372 /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
1373 /// "manual", "ci-run"). Empty = any kind.
1374 #[serde(default)]
1375 pub kinds: Vec<String>,
1376}
1377
1378impl PoolSelector {
1379 /// Does this selector match the given allocation routing key?
1380 /// Pure: no side effects.
1381 pub fn matches(&self, key: &MatchKey<'_>) -> bool {
1382 glob_any(&self.repos, key.repo)
1383 && glob_any(&self.branches, key.branch)
1384 && labels_subset(&self.pr_labels, key.pr_labels)
1385 && kind_any(&self.kinds, key.kind)
1386 }
1387
1388 /// Specificity score — higher = more specific. Used by the
1389 /// reconciler to break ties between selectors that all match.
1390 pub fn specificity(&self) -> u32 {
1391 let mut score = 0;
1392 if !self.repos.is_empty() {
1393 score += 8;
1394 }
1395 if !self.branches.is_empty() {
1396 score += 4;
1397 }
1398 score += (self.pr_labels.len() as u32) * 2;
1399 if !self.kinds.is_empty() {
1400 score += 1;
1401 }
1402 score
1403 }
1404}
1405
1406/// Allocation routing key — what the reconciler matches against pool selectors.
1407#[derive(Clone, Copy, Debug)]
1408pub struct MatchKey<'a> {
1409 pub repo: &'a str,
1410 pub branch: &'a str,
1411 pub pr_labels: &'a [String],
1412 pub kind: &'a str,
1413}
1414
1415fn glob_any(patterns: &[String], value: &str) -> bool {
1416 if patterns.is_empty() {
1417 return true;
1418 }
1419 patterns.iter().any(|p| glob_match(p, value))
1420}
1421
1422fn kind_any(kinds: &[String], value: &str) -> bool {
1423 if kinds.is_empty() {
1424 return true;
1425 }
1426 kinds.iter().any(|k| k == value)
1427}
1428
1429fn labels_subset(required: &[String], present: &[String]) -> bool {
1430 required.iter().all(|r| present.iter().any(|p| p == r))
1431}
1432
1433/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
1434/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
1435/// matches anything.
1436fn glob_match(pattern: &str, value: &str) -> bool {
1437 if pattern.is_empty() {
1438 return true;
1439 }
1440 if let Some(prefix) = pattern.strip_suffix('*') {
1441 value.starts_with(prefix)
1442 } else {
1443 pattern == value
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 // The closed-set tests below call `T::from_str(bad)` via the
1451 // derive-generated `FromStr` impls — bring the trait into scope at
1452 // the test module so the lib body doesn't carry an otherwise-unused
1453 // `use std::str::FromStr;` at the file head.
1454 use std::str::FromStr;
1455
1456 #[test]
1457 fn glob_trailing_star_matches_prefix() {
1458 assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
1459 assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
1460 assert!(glob_match("release-*", "release-2026-05"));
1461 assert!(!glob_match("release-*", "main"));
1462 assert!(glob_match("main", "main"));
1463 assert!(!glob_match("main", "develop"));
1464 }
1465
1466 #[test]
1467 fn empty_selector_matches_anything() {
1468 let s = PoolSelector::default();
1469 assert!(s.matches(&MatchKey {
1470 repo: "any/repo",
1471 branch: "any-branch",
1472 pr_labels: &[],
1473 kind: "any",
1474 }));
1475 }
1476
1477 #[test]
1478 fn repo_glob_filters_match_key() {
1479 let s = PoolSelector {
1480 repos: vec!["pleme-io/demo-*".into()],
1481 ..Default::default()
1482 };
1483 assert!(s.matches(&MatchKey {
1484 repo: "pleme-io/demo-app",
1485 branch: "x",
1486 pr_labels: &[],
1487 kind: "y",
1488 }));
1489 assert!(!s.matches(&MatchKey {
1490 repo: "pleme-io/other-repo",
1491 branch: "x",
1492 pr_labels: &[],
1493 kind: "y",
1494 }));
1495 }
1496
1497 #[test]
1498 fn pr_labels_require_all() {
1499 let s = PoolSelector {
1500 pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
1501 ..Default::default()
1502 };
1503 // Both labels present → match.
1504 assert!(s.matches(&MatchKey {
1505 repo: "x",
1506 branch: "y",
1507 pr_labels: &[
1508 "needs-ephemeral".into(),
1509 "integration".into(),
1510 "extra".into()
1511 ],
1512 kind: "z",
1513 }));
1514 // One label missing → no match.
1515 assert!(!s.matches(&MatchKey {
1516 repo: "x",
1517 branch: "y",
1518 pr_labels: &["needs-ephemeral".into()],
1519 kind: "z",
1520 }));
1521 }
1522
1523 #[test]
1524 fn specificity_ranks_more_constrained_higher() {
1525 let general = PoolSelector::default();
1526 let specific = PoolSelector {
1527 repos: vec!["pleme-io/*".into()],
1528 branches: vec!["main".into()],
1529 pr_labels: vec!["needs-ephemeral".into()],
1530 kinds: vec!["github-pr".into()],
1531 };
1532 assert!(specific.specificity() > general.specificity());
1533 }
1534
1535 #[test]
1536 fn return_policy_defaults_to_replace() {
1537 assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
1538 }
1539
1540 #[test]
1541 fn pool_phase_defaults_to_initializing() {
1542 assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
1543 }
1544
1545 // ── closed-set algebra contracts for ReplacementPolicy
1546 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1547
1548 /// Structural well-formedness of [`ReplacementPolicy`] as a
1549 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1550 /// testkit lift that pins all three structural invariants (`ALL`
1551 /// is non-empty, every variant round-trips through
1552 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1553 /// outside the closed set) at ONE call site. Replaces the hand-
1554 /// derived `replacement_policy_all_is_unique_and_complete` +
1555 /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
1556 /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
1557 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1558 /// exercises the same code path the pool reconciler hits when
1559 /// parsing a CRD `enum:`-validated value back to the typed policy.
1560 #[test]
1561 fn replacement_policy_is_well_formed_closed_set() {
1562 tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
1563 }
1564
1565 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1566 /// output verbatim for every variant. A future variant rename (or
1567 /// an `as_str` arm typo) lands here at one site, instead of
1568 /// drifting between the typed surface, the CRD enum, and the
1569 /// YAML wire format.
1570 #[test]
1571 fn replacement_policy_as_str_matches_serde() {
1572 crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
1573 }
1574
1575 /// The Display impl IS `as_str` — pinning this lets future callers
1576 /// reach for either projection without drift. The operator-facing
1577 /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1578 /// composes through Display rather than through a hard-coded
1579 /// variant string.
1580 #[test]
1581 fn replacement_policy_display_matches_as_str() {
1582 crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
1583 }
1584
1585 /// `FromStr` rejects strings that aren't in the canonical
1586 /// projection — lowercased / typo / cross-axis-leaked — and the
1587 /// error echoes the input verbatim so the operator-facing
1588 /// diagnostic carries the offending value, not a normalized form.
1589 /// The empty-input arm is pinned by
1590 /// [`replacement_policy_is_well_formed_closed_set`] via the
1591 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1592 /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1593 /// newtype, which the trait's `make_unknown` can't see.
1594 #[test]
1595 fn unknown_replacement_policy_errors() {
1596 for bad in [
1597 "replaceimmediate",
1598 "PAUSEPOOL",
1599 "Replace-Immediate",
1600 "hold_failed",
1601 "Pause",
1602 "Reset",
1603 ] {
1604 let err = ReplacementPolicy::from_str(bad).unwrap_err();
1605 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1606 }
1607 }
1608
1609 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1610 /// documented per-variant on-failure behavior.
1611 #[test]
1612 fn replacement_policy_predicate_truth_tables() {
1613 assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1614 assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1615
1616 assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1617 assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1618
1619 assert!(!ReplacementPolicy::PausePool.replaces_failed());
1620 assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1621 }
1622
1623 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1624 /// predicates simultaneously — the two on-failure actions
1625 /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1626 /// A future `ReplacementPolicy::PauseAndReap` that returned true
1627 /// from both would FAIL here, forcing the author to either pick
1628 /// one bucket or extend the consumer dispatch site in
1629 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1630 /// deliberately rather than silently double-firing both branches.
1631 #[test]
1632 fn replacement_policy_predicates_are_disjoint() {
1633 for policy in ReplacementPolicy::ALL {
1634 assert!(
1635 !(policy.replaces_failed() && policy.pauses_on_failure()),
1636 "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1637 );
1638 }
1639 }
1640
1641 /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1642 /// pauses_on_failure)` is injective across `ALL`. Each variant
1643 /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1644 /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1645 /// this with the disjointness contract above forces a future
1646 /// variant to land in a fresh `(replaces_failed,
1647 /// pauses_on_failure)` bucket — or the author extends the consumer
1648 /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1649 /// to recognize the new projection bucket.
1650 #[test]
1651 fn replacement_policy_predicate_pair_is_injective() {
1652 let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1653 .into_iter()
1654 .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1655 .collect();
1656 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1657 assert_eq!(
1658 projections.len(),
1659 unique.len(),
1660 "predicate pair projection is not injective: {projections:?}",
1661 );
1662 }
1663
1664 /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1665 /// returns the variant tagged `#[default]` in the enum, AND that
1666 /// variant reaps (the production-safe behavior). A future #[default]
1667 /// rename without flipping the predicates fails here.
1668 #[test]
1669 fn replacement_policy_default_replaces_failed() {
1670 let d = ReplacementPolicy::default();
1671 assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1672 assert!(d.replaces_failed());
1673 assert!(!d.pauses_on_failure());
1674 }
1675
1676 #[test]
1677 fn kinds_filter_to_known_set() {
1678 let s = PoolSelector {
1679 kinds: vec!["github-pr".into(), "manual".into()],
1680 ..Default::default()
1681 };
1682 assert!(s.matches(&MatchKey {
1683 repo: "x",
1684 branch: "y",
1685 pr_labels: &[],
1686 kind: "github-pr",
1687 }));
1688 assert!(!s.matches(&MatchKey {
1689 repo: "x",
1690 branch: "y",
1691 pr_labels: &[],
1692 kind: "scheduled",
1693 }));
1694 }
1695
1696 // ── closed-set algebra contracts for ReturnPolicy
1697 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1698
1699 /// Structural well-formedness of [`ReturnPolicy`] as a
1700 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1701 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1702 /// above.
1703 #[test]
1704 fn return_policy_is_well_formed_closed_set() {
1705 tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1706 }
1707
1708 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1709 /// output verbatim for every variant. A future variant rename (or
1710 /// an `as_str` arm typo) lands here at one site, instead of
1711 /// drifting between the typed surface, the CRD enum, and the
1712 /// YAML wire format.
1713 #[test]
1714 fn return_policy_as_str_matches_serde() {
1715 crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
1716 }
1717
1718 /// The Display impl IS `as_str` — pinning this lets future callers
1719 /// reach for either projection without drift, mirroring the
1720 /// `ReplacementPolicy` discipline.
1721 #[test]
1722 fn return_policy_display_matches_as_str() {
1723 crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
1724 }
1725
1726 /// `FromStr` rejects strings that aren't in the canonical
1727 /// projection — lowercased / typo / cross-axis-leaked — and the
1728 /// error echoes the input verbatim so the operator-facing
1729 /// diagnostic carries the offending value, not a normalized form.
1730 /// The empty-input arm is pinned by
1731 /// [`return_policy_is_well_formed_closed_set`] via the
1732 /// `tatara_lisp::ClosedSet` testkit.
1733 #[test]
1734 fn unknown_return_policy_errors() {
1735 for bad in [
1736 "replace",
1737 "RESET",
1738 "Re-place",
1739 "keep_for_inspection",
1740 "DeleteAndRespawn",
1741 "ReplaceImmediate",
1742 ] {
1743 let err = ReturnPolicy::from_str(bad).unwrap_err();
1744 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1745 }
1746 }
1747
1748 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1749 /// documented per-variant on-release behavior.
1750 #[test]
1751 fn return_policy_predicate_truth_tables() {
1752 assert!(!ReturnPolicy::Replace.keeps_process());
1753 assert!(!ReturnPolicy::Replace.runs_reset_job());
1754
1755 assert!(ReturnPolicy::Reset.keeps_process());
1756 assert!(ReturnPolicy::Reset.runs_reset_job());
1757
1758 assert!(ReturnPolicy::Keep.keeps_process());
1759 assert!(!ReturnPolicy::Keep.runs_reset_job());
1760 }
1761
1762 /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1763 /// You cannot run a typed `:reset` Job against a Process you've
1764 /// just deleted; the impossible bucket `(false, true)` must stay
1765 /// empty. A future variant returning true from `runs_reset_job`
1766 /// while returning false from `keeps_process` fails here, which
1767 /// forces the author to either flip `keeps_process` to true or
1768 /// extend the consumer dispatch site in
1769 /// `tatara-pool-reconciler::return_policy::plan_return`
1770 /// deliberately rather than letting an impossible state slip in.
1771 #[test]
1772 fn return_policy_reset_implies_keeps_process() {
1773 for policy in ReturnPolicy::ALL {
1774 if policy.runs_reset_job() {
1775 assert!(
1776 policy.keeps_process(),
1777 "{policy:?} runs a reset job but does not keep the process",
1778 );
1779 }
1780 }
1781 }
1782
1783 /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1784 /// is injective across `ALL`. Each variant projects to its own
1785 /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1786 /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1787 /// inspection. Pairing this with the implication contract above
1788 /// forces a future variant to land in a fresh
1789 /// `(keeps_process, runs_reset_job)` bucket — or the author
1790 /// extends the consumer dispatch in
1791 /// `tatara-pool-reconciler::return_policy::plan_return` to
1792 /// recognize the new projection bucket.
1793 #[test]
1794 fn return_policy_predicate_pair_is_injective() {
1795 let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1796 .into_iter()
1797 .map(|p| (p.keeps_process(), p.runs_reset_job()))
1798 .collect();
1799 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1800 assert_eq!(
1801 projections.len(),
1802 unique.len(),
1803 "predicate pair projection is not injective: {projections:?}",
1804 );
1805 }
1806
1807 /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1808 /// the variant tagged `#[default]` in the enum, AND that variant
1809 /// is the safe "tear down + respawn" behavior — neither keeps the
1810 /// process nor runs a reset Job. A future `#[default]` rename
1811 /// without flipping the predicates fails here.
1812 #[test]
1813 fn return_policy_default_is_replace_and_neither_predicate_fires() {
1814 let d = ReturnPolicy::default();
1815 assert_eq!(d, ReturnPolicy::Replace);
1816 assert!(!d.keeps_process());
1817 assert!(!d.runs_reset_job());
1818 }
1819
1820 // ── closed-set algebra contracts for MemberState
1821 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1822
1823 /// Structural well-formedness of [`MemberState`] as a
1824 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1825 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1826 /// and [`return_policy_is_well_formed_closed_set`] above.
1827 #[test]
1828 fn member_state_is_well_formed_closed_set() {
1829 tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1830 }
1831
1832 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1833 /// output verbatim for every variant. A future variant rename (or
1834 /// an `as_str` arm typo) lands here at one site, instead of
1835 /// drifting between the typed surface, the CRD enum, and the YAML
1836 /// wire format the pool reconciler stamps on
1837 /// `status.members[].state`.
1838 #[test]
1839 fn member_state_as_str_matches_serde() {
1840 crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
1841 }
1842
1843 /// The Display impl IS `as_str` — pinning this lets future callers
1844 /// reach for either projection without drift. Any operator-facing
1845 /// "state={state}" diagnostic that composes through Display
1846 /// inherits the canonical wire-format string automatically.
1847 #[test]
1848 fn member_state_display_matches_as_str() {
1849 crate::tagged_union::assert_display_matches_label::<MemberState>();
1850 }
1851
1852 /// `FromStr` rejects strings that aren't in the canonical
1853 /// projection — lowercased / typo / cross-axis-leaked — and
1854 /// the error echoes the input verbatim so the operator-facing
1855 /// diagnostic carries the offending value, not a normalized form.
1856 /// The empty-input arm is pinned by
1857 /// [`member_state_is_well_formed_closed_set`] via the
1858 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1859 /// pin the closed-set REJECTION contract that the trait can't see:
1860 /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1861 /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1862 /// `ProcessPhase`) but MUST reject here, because the codomains
1863 /// are disjoint.
1864 #[test]
1865 fn unknown_member_state_errors() {
1866 for bad in [
1867 "free",
1868 "SPAWNING",
1869 "Free-State",
1870 "allocated_now",
1871 "ReplaceImmediate", // ReplacementPolicy-axis leak
1872 "Reset", // ReturnPolicy-axis leak
1873 "Attested", // ProcessPhase-axis leak
1874 ] {
1875 let err = MemberState::from_str(bad).unwrap_err();
1876 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1877 }
1878 }
1879
1880 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1881 /// documented per-variant lifecycle role. The pool reconciler's
1882 /// `pool_phase_from_members` supply calc collapses
1883 /// `count_state(Free) + count_state(Spawning)` into one
1884 /// `counts_toward_supply` filter; this table pins the per-variant
1885 /// projection that consumer depends on.
1886 #[test]
1887 fn member_state_predicate_truth_tables() {
1888 assert!(!MemberState::Spawning.is_failed());
1889 assert!(MemberState::Spawning.counts_toward_supply());
1890
1891 assert!(!MemberState::Free.is_failed());
1892 assert!(MemberState::Free.counts_toward_supply());
1893
1894 assert!(!MemberState::Allocated.is_failed());
1895 assert!(!MemberState::Allocated.counts_toward_supply());
1896
1897 assert!(!MemberState::Returning.is_failed());
1898 assert!(!MemberState::Returning.counts_toward_supply());
1899
1900 assert!(MemberState::Failed.is_failed());
1901 assert!(!MemberState::Failed.counts_toward_supply());
1902 }
1903
1904 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1905 /// `is_failed` and `counts_toward_supply` simultaneously — a
1906 /// failed member can never be counted as available capacity. A
1907 /// future variant that returned true from both would FAIL here,
1908 /// forcing the author to either drop it from supply, or extend
1909 /// the consumer's bucketing in
1910 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1911 /// deliberately rather than silently inflating the pool's supply
1912 /// count with failed slots.
1913 #[test]
1914 fn member_state_failed_implies_no_supply() {
1915 for state in MemberState::ALL {
1916 assert!(
1917 !(state.is_failed() && state.counts_toward_supply()),
1918 "{state:?} returns true from both is_failed and counts_toward_supply — \
1919 a failed member can never be counted as available pool capacity",
1920 );
1921 }
1922 }
1923
1924 /// COVERAGE CONTRACT: every variant lands somewhere — either
1925 /// in supply, or as a failed slot, or as an in-use bucket
1926 /// (`Allocated | Returning`). A future variant that returns
1927 /// `false` from `counts_toward_supply` AND `false` from
1928 /// `is_failed` is fine *iff* it represents an in-use slot; this
1929 /// test pins the existing variants in their declared buckets so
1930 /// the consumer-side dispatch in
1931 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1932 /// stays grounded.
1933 #[test]
1934 fn member_state_buckets_cover_every_variant() {
1935 let mut supply = 0u32;
1936 let mut failed = 0u32;
1937 let mut in_use = 0u32;
1938 for state in MemberState::ALL {
1939 match (state.is_failed(), state.counts_toward_supply()) {
1940 (true, false) => failed += 1,
1941 (false, true) => supply += 1,
1942 (false, false) => in_use += 1,
1943 (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1944 }
1945 }
1946 assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1947 assert_eq!(failed, 1, "failed bucket: Failed");
1948 assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1949 assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1950 }
1951
1952 // ── closed-set algebra contracts for PoolPhase
1953 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1954
1955 /// Structural well-formedness of [`PoolPhase`] as a
1956 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1957 /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1958 #[test]
1959 fn pool_phase_is_well_formed_closed_set() {
1960 tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1961 }
1962
1963 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1964 /// output verbatim for every variant. A future variant rename (or
1965 /// an `as_str` arm typo) lands here at one site, instead of
1966 /// drifting between the typed surface, the CRD enum, and the YAML
1967 /// wire format the pool reconciler stamps on `status.phase`.
1968 #[test]
1969 fn pool_phase_as_str_matches_serde() {
1970 crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
1971 }
1972
1973 /// The Display impl IS `as_str` — pinning this lets future callers
1974 /// reach for either projection without drift. Any operator-facing
1975 /// "phase={phase}" diagnostic that composes through Display
1976 /// inherits the canonical wire-format string automatically.
1977 #[test]
1978 fn pool_phase_display_matches_as_str() {
1979 crate::tagged_union::assert_display_matches_label::<PoolPhase>();
1980 }
1981
1982 /// `FromStr` rejects strings that aren't in the canonical
1983 /// projection — lowercased / typo / cross-axis-leaked — and
1984 /// the error echoes the input verbatim so the operator-facing
1985 /// diagnostic carries the offending value, not a normalized form.
1986 /// The empty-input arm is pinned by
1987 /// [`pool_phase_is_well_formed_closed_set`] via the
1988 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1989 /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1990 /// closed-set REJECTION contract that the trait can't see — those
1991 /// are valid sibling-axis labels but MUST reject here.
1992 #[test]
1993 fn unknown_pool_phase_errors() {
1994 for bad in [
1995 "steady",
1996 "SCALINGUP",
1997 "Scaling-Up",
1998 "scaling_down",
1999 "Free", // MemberState-axis leak
2000 "Replace", // ReturnPolicy-axis leak
2001 "Attested", // ProcessPhase-axis leak
2002 "HoldFailed", // ReplacementPolicy-axis leak
2003 ] {
2004 let err = PoolPhase::from_str(bad).unwrap_err();
2005 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2006 }
2007 }
2008
2009 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2010 /// documented per-variant lifecycle role. Pinning this table at
2011 /// one site means any future status-aggregator surface
2012 /// (`feira pool list --healthy`, the SSE filter, the desired-loop
2013 /// heartbeat short-circuit) reads the same projection that the
2014 /// reconciler writes.
2015 #[test]
2016 fn pool_phase_predicate_truth_tables() {
2017 assert!(!PoolPhase::Initializing.is_steady());
2018 assert!(!PoolPhase::Initializing.is_terminal());
2019
2020 assert!(PoolPhase::Steady.is_steady());
2021 assert!(!PoolPhase::Steady.is_terminal());
2022
2023 assert!(!PoolPhase::ScalingUp.is_steady());
2024 assert!(!PoolPhase::ScalingUp.is_terminal());
2025
2026 assert!(!PoolPhase::ScalingDown.is_steady());
2027 assert!(!PoolPhase::ScalingDown.is_terminal());
2028
2029 assert!(!PoolPhase::Degraded.is_steady());
2030 assert!(!PoolPhase::Degraded.is_terminal());
2031
2032 assert!(!PoolPhase::Draining.is_steady());
2033 assert!(PoolPhase::Draining.is_terminal());
2034 }
2035
2036 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2037 /// `is_steady` and `is_terminal` simultaneously — a draining pool
2038 /// is by definition transitioning OUT, not the goal converged
2039 /// state. A future variant that returned true from both would
2040 /// FAIL here, forcing the author to either pick one bucket or
2041 /// extend the consumer dispatch sites (status aggregators,
2042 /// heartbeat short-circuit) deliberately rather than silently
2043 /// double-firing both branches.
2044 #[test]
2045 fn pool_phase_steady_excludes_terminal() {
2046 for phase in PoolPhase::ALL {
2047 assert!(
2048 !(phase.is_steady() && phase.is_terminal()),
2049 "{phase:?} returns true from both is_steady and is_terminal — \
2050 a draining pool is by definition not the converged goal state",
2051 );
2052 }
2053 }
2054
2055 /// COVERAGE CONTRACT: every variant lands somewhere — either the
2056 /// converged goal (`Steady`), the absorbing exit (`Draining`),
2057 /// or the transient bucket (`Initializing | ScalingUp |
2058 /// ScalingDown | Degraded` — pool is in motion toward desired).
2059 /// A future variant that returns `false` from BOTH predicates is
2060 /// fine *iff* it represents an in-motion state; this test pins
2061 /// the existing variants in their declared buckets so the
2062 /// projection consumers stay grounded.
2063 #[test]
2064 fn pool_phase_buckets_cover_every_variant() {
2065 let mut converged = 0u32;
2066 let mut terminal = 0u32;
2067 let mut transient = 0u32;
2068 for phase in PoolPhase::ALL {
2069 match (phase.is_steady(), phase.is_terminal()) {
2070 (true, false) => converged += 1,
2071 (false, true) => terminal += 1,
2072 (false, false) => transient += 1,
2073 (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
2074 }
2075 }
2076 assert_eq!(converged, 1, "converged bucket: Steady");
2077 assert_eq!(terminal, 1, "terminal bucket: Draining");
2078 assert_eq!(
2079 transient, 4,
2080 "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
2081 );
2082 assert_eq!(
2083 converged + terminal + transient,
2084 PoolPhase::ALL.len() as u32
2085 );
2086 }
2087
2088 /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
2089 /// variant a freshly-admitted pool should land in — `Initializing`
2090 /// — AND that variant is neither steady (no members yet) nor
2091 /// terminal (not deletion-stamped). A future `Default` rename
2092 /// without flipping the predicates fails here.
2093 #[test]
2094 fn pool_phase_default_is_initializing_in_transient_bucket() {
2095 let d = PoolPhase::default();
2096 assert_eq!(d, PoolPhase::Initializing);
2097 assert!(!d.is_steady());
2098 assert!(!d.is_terminal());
2099 }
2100
2101 // ─────────────────────────────────────────────────────────────────
2102 // `EphemeralPool::name_or_empty` — borrow-form metadata-projection
2103 // primitive on the `metadata.name` axis. Pins the missing-slot
2104 // corner, the populated-slot corner, the pre-lift chain-shape
2105 // parity, and the pure-projection discipline that the two
2106 // `tatara-pool-reconciler` consumers routed onto the primitive
2107 // depend on. See the primitive's doc-comment for the full
2108 // migration rationale.
2109 // ─────────────────────────────────────────────────────────────────
2110
2111 fn empty_template() -> EphemeralSpec {
2112 EphemeralSpec {
2113 aplicacao: crate::intent::AplicacaoIntent {
2114 chart_ref: "oci://x".into(),
2115 version: "1".into(),
2116 profile: String::new(),
2117 values_overlay: serde_json::Value::Null,
2118 release_name: None,
2119 target_namespace: None,
2120 install_timeout: None,
2121 },
2122 ttl: "1h".into(),
2123 teardown: crate::lifetime::TeardownPolicy::Always,
2124 max_concurrent: 0,
2125 postconditions: vec![],
2126 preconditions: vec![],
2127 verify_timeout: None,
2128 classification: None,
2129 parent: None,
2130 exports: vec![],
2131 routing: None,
2132 }
2133 }
2134
2135 fn pool_spec() -> PoolSpec {
2136 PoolSpec {
2137 desired_size: 1,
2138 min_size: 0,
2139 max_size: 0,
2140 return_policy: ReturnPolicy::Replace,
2141 selector: PoolSelector::default(),
2142 template: empty_template(),
2143 free_ttl: "24h".into(),
2144 max_allocation_ttl: "4h".into(),
2145 desired: 0,
2146 replacement_policy: ReplacementPolicy::default(),
2147 stable_name_claim: false,
2148 }
2149 }
2150
2151 fn pool_named(name: &str) -> EphemeralPool {
2152 EphemeralPool::new(name, pool_spec())
2153 }
2154
2155 fn pool_unnamed() -> EphemeralPool {
2156 let mut p = EphemeralPool::new("scratch", pool_spec());
2157 p.metadata.name = None;
2158 p
2159 }
2160
2161 #[test]
2162 fn name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2163 let p = pool_unnamed();
2164 assert!(p.metadata.name.is_none(), "fixture invariant");
2165 assert_eq!(p.name_or_empty(), "");
2166 }
2167
2168 #[test]
2169 fn name_or_empty_returns_populated_slot_verbatim() {
2170 let p = pool_named("attest-pool");
2171 assert_eq!(p.name_or_empty(), "attest-pool");
2172 }
2173
2174 #[test]
2175 fn name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2176 // Corner between `None` (missing slot) and `Some(String::new())`
2177 // (populated slot containing the empty string): the primitive
2178 // MUST fold both to the same `""` byte-shape so a downstream
2179 // `HashMap<String,_>::get(name)` / `str::cmp` sees ONE
2180 // "unnamed pool" bucket regardless of which shape the K8s API
2181 // server materialized. This is byte-identical to what the
2182 // pre-lift `.as_deref().unwrap_or("")` chain produced.
2183 let mut p = pool_named("scratch");
2184 p.metadata.name = Some(String::new());
2185 assert_eq!(p.name_or_empty(), "");
2186 }
2187
2188 #[test]
2189 fn name_or_empty_is_a_pure_projection() {
2190 // Consecutive calls return byte-identical slices — no cached
2191 // state, no mutation on the `EphemeralPool` between calls.
2192 // Guards against a future refactor that plants a cache field
2193 // and drifts one caller from another silently.
2194 let p = pool_named("router-pool");
2195 assert_eq!(p.name_or_empty(), p.name_or_empty());
2196 assert_eq!(p.name_or_empty(), "router-pool");
2197 assert_eq!(p.name_or_empty(), "router-pool");
2198 }
2199
2200 #[test]
2201 fn name_or_empty_matches_pre_lift_chain_verbatim() {
2202 // Byte-identical parity with the two hand-authored
2203 // `.metadata.name.as_deref().unwrap_or("")` chains the
2204 // primitive replaces in `tatara-pool-reconciler::router` and
2205 // `tatara-pool-reconciler::controller_allocation`. Runs across
2206 // the FULL corner set of the metadata.name slot: absent,
2207 // present-with-value, present-with-empty-string.
2208 let cases: [(Option<String>, &str); 3] = [
2209 (None, ""),
2210 (Some("attest-pool".into()), "attest-pool"),
2211 (Some(String::new()), ""),
2212 ];
2213 for (slot, expected) in cases {
2214 let mut p = pool_named("scratch");
2215 p.metadata.name = slot.clone();
2216 let pre_lift = p.metadata.name.as_deref().unwrap_or("");
2217 assert_eq!(pre_lift, expected, "pre-lift chain sanity");
2218 assert_eq!(p.name_or_empty(), pre_lift);
2219 assert_eq!(p.name_or_empty(), expected);
2220 }
2221 }
2222
2223 #[test]
2224 fn name_or_empty_borrows_from_metadata_name_slot() {
2225 // The returned `&str` is tied to the `EphemeralPool`'s
2226 // lifetime — the caller can compare / hash / index without
2227 // allocating. This is the load-bearing property that lets
2228 // the `HashMap<String, _>::get(pool.name_or_empty())` closure
2229 // in `controller_allocation::reconcile_inner` skip cloning.
2230 let p = pool_named("attest-pool");
2231 let s: &str = p.name_or_empty();
2232 assert_eq!(s.as_ptr(), p.metadata.name.as_deref().unwrap().as_ptr());
2233 }
2234
2235 // ─── EphemeralPool::owned_name_or_empty substrate pins ────────────
2236 //
2237 // The owned-form peer of the borrow-form `name_or_empty` primitive
2238 // above. Sibling to the sister-CRD primitive
2239 // `crate::crd::Process::owned_name_or_empty` (owned + empty sentinel
2240 // on `Process::metadata.name`) — the four primitives now partition
2241 // the (borrow × owned) × (name × uid) corner of the metadata-slot
2242 // family on identical missing-slot semantics across BOTH tatara-
2243 // process CRDs (`Process::uid_or_empty` + `Process::owned_name_or_empty`
2244 // + `EphemeralPool::name_or_empty` + this method). Fail-before-pass-
2245 // after granularity: `owned_name_or_empty` did not exist on the pool
2246 // CRD pre-lift; the compiler cannot resolve the name until the impl
2247 // block above is in place, so a rollback of the primitive breaks
2248 // this whole module.
2249 #[test]
2250 fn owned_name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2251 let p = pool_unnamed();
2252 assert!(p.metadata.name.is_none(), "fixture invariant");
2253 assert_eq!(p.owned_name_or_empty(), String::new());
2254 }
2255
2256 #[test]
2257 fn owned_name_or_empty_returns_owned_string_when_slot_is_populated() {
2258 let p = pool_named("attest-pool");
2259 assert_eq!(p.owned_name_or_empty(), "attest-pool");
2260 }
2261
2262 #[test]
2263 fn owned_name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2264 // Corner between `None` (missing slot) and `Some(String::new())`
2265 // (populated slot containing the empty string): the primitive
2266 // MUST fold both to the same `""` byte-shape so a downstream
2267 // `HashMap<String,_>::get(name)` sees ONE "unnamed pool" bucket
2268 // regardless of which shape the K8s API server materialized.
2269 // Byte-identical to what the pre-lift `.clone().unwrap_or_default()`
2270 // chain produced.
2271 let mut p = pool_named("scratch");
2272 p.metadata.name = Some(String::new());
2273 assert_eq!(p.owned_name_or_empty(), String::new());
2274 assert!(p.owned_name_or_empty().is_empty());
2275 }
2276
2277 #[test]
2278 fn owned_name_or_empty_is_a_pure_projection() {
2279 // Consecutive calls return byte-identical Strings — no cached
2280 // state, no mutation on the `EphemeralPool` between calls.
2281 // Guards against a future refactor that plants a cache field
2282 // and drifts one caller from another silently.
2283 let p = pool_named("router-pool");
2284 assert_eq!(p.owned_name_or_empty(), p.owned_name_or_empty());
2285 assert_eq!(p.owned_name_or_empty(), "router-pool");
2286 assert_eq!(p.owned_name_or_empty(), "router-pool");
2287 }
2288
2289 #[test]
2290 fn owned_name_or_empty_matches_pre_lift_chain_verbatim() {
2291 // Byte-identical parity with the two hand-authored
2292 // `.metadata.name.clone().unwrap_or_default()` chains the
2293 // primitive replaces in `tatara-pool-reconciler::
2294 // controller_allocation::reconcile_inner` (HashMap key seed)
2295 // and `tatara-pool-reconciler::allocation_decide::
2296 // AllocationConvergenceCtx::observe` (AllocationRef.name slot
2297 // seed). Runs across the FULL corner set of the metadata.name
2298 // slot: absent, present-with-value, present-with-empty-string.
2299 // A regression that inserted a normalization step at the
2300 // primitive the pre-lift chain does NOT apply — or vice versa —
2301 // surfaces here rather than as silent drift between the two
2302 // owned-form callsites and the ONE substrate owner they now
2303 // route through.
2304 let cases: [(Option<String>, &str); 3] = [
2305 (None, ""),
2306 (Some("attest-pool".into()), "attest-pool"),
2307 (Some(String::new()), ""),
2308 ];
2309 for (slot, expected) in cases {
2310 let mut p = pool_named("scratch");
2311 p.metadata.name = slot.clone();
2312 let pre_lift = p.metadata.name.clone().unwrap_or_default();
2313 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2314 assert_eq!(p.owned_name_or_empty(), pre_lift);
2315 assert_eq!(p.owned_name_or_empty().as_str(), expected);
2316 }
2317 }
2318
2319 #[test]
2320 fn owned_name_or_empty_matches_borrow_form_peer_on_populated_slot() {
2321 // Cross-primitive coherence pin at the sibling corner: when the
2322 // slot is present, the borrow-form (`name_or_empty`) and owned-
2323 // form (`owned_name_or_empty`) primitives return the SAME byte
2324 // sequence and differ only in ownership. A regression that
2325 // skewed one form's fallback would surface here rather than as
2326 // silent drift between the router tie-break comparator and the
2327 // AllocationRef seed on the SAME pool.
2328 let p = pool_named("attest-pool");
2329 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2330 }
2331
2332 #[test]
2333 fn owned_name_or_empty_matches_borrow_form_peer_on_missing_slot() {
2334 // Sibling corner of the coherence pin above: when the slot is
2335 // absent (or explicitly empty), BOTH primitives fold to the
2336 // same empty-string byte-shape. The load-bearing property is
2337 // that a caller who switches between the two return-forms
2338 // based on downstream ownership requirements never sees a
2339 // different missing-slot spelling as a side effect.
2340 let p = pool_unnamed();
2341 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2342 assert_eq!(p.name_or_empty(), "");
2343 assert_eq!(p.owned_name_or_empty(), String::new());
2344 }
2345
2346 // ─── EphemeralPool::is_being_deleted substrate pins ───────────────
2347 //
2348 // Pins the copy-form metadata-projection primitive on the deletion-
2349 // tombstone axis of the pool CRD. Peer to the borrow-form + owned-
2350 // form metadata-fallback family (`name_or_empty`,
2351 // `owned_name_or_empty`); this one opens the presence-probe corner
2352 // for the tombstone slot. Sibling to the sister-CRD primitive
2353 // `crate::crd::Process::is_being_deleted` — the two primitives
2354 // now partition the tombstone-presence probe across BOTH tatara-
2355 // process CRDs on identical missing-slot semantics. Fail-before-
2356 // pass-after granularity: `is_being_deleted` did not exist on the
2357 // pool CRD pre-lift; the compiler cannot resolve the name until
2358 // the impl block above is in place, so a rollback of the primitive
2359 // breaks this whole module.
2360
2361 fn tombstoned_pool() -> EphemeralPool {
2362 let mut p = pool_named("attest-pool");
2363 p.metadata.namespace = Some("ephemeral-pools".into());
2364 p.metadata.deletion_timestamp = Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2365 Utc::now(),
2366 ));
2367 p
2368 }
2369
2370 #[test]
2371 fn is_being_deleted_returns_false_when_deletion_timestamp_is_absent() {
2372 // Missing-tombstone corner pin: the primitive collapses the
2373 // no-tombstone case to `false` so the `→ Drain` short-circuit
2374 // at `decide_pool_reconcile` is NOT taken and the observed-
2375 // phase composer at `pool_phase_from_members` proceeds to its
2376 // normal (free / spawning / allocated) arithmetic branches
2377 // instead of short-circuiting to `PoolPhase::Draining`.
2378 // Matches the pre-lift `.is_some()` chain's `false` byte-
2379 // identically at every consumer's downstream gate.
2380 let mut p = pool_named("attest-pool");
2381 p.metadata.deletion_timestamp = None;
2382 assert!(!p.is_being_deleted());
2383 }
2384
2385 #[test]
2386 fn is_being_deleted_returns_true_when_deletion_timestamp_is_present() {
2387 // Present-tombstone corner pin: the primitive returns `true`
2388 // on any populated `metadata.deletionTimestamp` slot regardless
2389 // of the timestamp payload — the two consumers only read the
2390 // tombstone's PRESENCE, never its RFC-3339 timestamp value.
2391 // A regression that gated the `true` return on the timestamp
2392 // being non-epoch, or parsed the timestamp before returning,
2393 // would surface here rather than as silent skew at the
2394 // `→ Drain` decision or the `→ Draining` phase report on the
2395 // SAME `EphemeralPool`.
2396 let p = tombstoned_pool();
2397 assert!(p.is_being_deleted());
2398 }
2399
2400 #[test]
2401 fn is_being_deleted_is_a_pure_projection() {
2402 // Purity pin: two consecutive calls return byte-identical
2403 // `bool` values (no lazy materialization, no interior
2404 // mutation of `self`). Peer to the sibling
2405 // `name_or_empty_is_a_pure_projection` +
2406 // `owned_name_or_empty_is_a_pure_projection` pins in this
2407 // module and to `is_being_deleted_is_a_pure_projection` on
2408 // the sister-CRD `Process`; all four bind the pure-projection
2409 // discipline on the ONE substrate accessor per metadata slot.
2410 let p = tombstoned_pool();
2411 let a = p.is_being_deleted();
2412 let b = p.is_being_deleted();
2413 assert_eq!(a, b);
2414 assert!(a);
2415 }
2416
2417 #[test]
2418 fn is_being_deleted_matches_pre_lift_pool_reconciler_chain_shape() {
2419 // Parity pin: sweeps the two corners every pre-lift consumer
2420 // plausibly encountered (missing tombstone, present tombstone)
2421 // and compares the substrate call against a hand-authored pre-
2422 // lift chain byte-identically. A regression that reshaped
2423 // either corner would surface here rather than as silent
2424 // operator-facing skew between the pool-reconciler's `→ Drain`
2425 // decision and the observed-phase composer's `→ Draining`
2426 // report on the SAME `EphemeralPool` within one reconcile
2427 // pass.
2428 fn pre_lift(p: &EphemeralPool) -> bool {
2429 p.metadata.deletion_timestamp.is_some()
2430 }
2431 // Missing slot.
2432 let mut p = pool_named("attest-pool");
2433 p.metadata.deletion_timestamp = None;
2434 assert_eq!(p.is_being_deleted(), pre_lift(&p));
2435 // Populated slot.
2436 let p = tombstoned_pool();
2437 assert_eq!(p.is_being_deleted(), pre_lift(&p));
2438 }
2439
2440 #[test]
2441 fn is_being_deleted_composes_with_pool_phase_draining_at_reconcile_preempt() {
2442 // Call-site-shape pin: the `pool_phase_from_members`
2443 // deletion-preempt returns `PoolPhase::Draining` as soon as
2444 // `pool.is_being_deleted()` holds, regardless of the (free +
2445 // spawning) supply arithmetic that would otherwise pick
2446 // `Ready` / `Scaling` / `Degraded`. The `→ Drain` decision at
2447 // `decide_pool_reconcile` composes with the same probe on the
2448 // same tombstone-presence slot. A regression that broadened
2449 // the tombstone probe implicitly (returning `false` on a
2450 // present but zero-timestamp) or narrowed it (requiring an
2451 // additional `.finalizers.is_empty()` conjunct that the two
2452 // consumers never spelled) would surface here rather than as
2453 // silent operator-facing skew between the pool reconciler's
2454 // decision and the observed-phase composer on the SAME
2455 // `EphemeralPool` within one reconcile pass.
2456 let alive = pool_named("attest-pool");
2457 assert!(!alive.is_being_deleted());
2458 let dying = tombstoned_pool();
2459 assert!(dying.is_being_deleted());
2460 }
2461
2462 // ─── EphemeralPool::owned_namespace_or_empty substrate pins ───────
2463 //
2464 // The owned-form peer of the `owned_name_or_empty` primitive on the
2465 // sibling `metadata.namespace` axis — the paired half of the
2466 // `AllocationRef { name, namespace }` struct literal both
2467 // `AllocationConvergenceCtx::observe` and the composition pin
2468 // consume through the SAME `AllocationRef::new(name, namespace)`
2469 // constructor. Fail-before-pass-after granularity:
2470 // `owned_namespace_or_empty` did not exist on the pool CRD pre-
2471 // lift; the compiler cannot resolve the name until the impl block
2472 // above is in place, so a rollback of the primitive breaks this
2473 // whole module.
2474 #[test]
2475 fn owned_namespace_or_empty_returns_empty_string_when_metadata_namespace_is_none() {
2476 // Missing-slot corner pin: the primitive collapses the no-
2477 // namespace case to the load-bearing empty-string sentinel so
2478 // the downstream `AllocationRef.namespace` slot carries `""`
2479 // rather than a defaulted `"default"` string. See the doc-
2480 // comment's DELIBERATE-EMPTY-SENTINEL rationale for why the
2481 // fallback matches `.clone().unwrap_or_default()` byte-for-
2482 // byte rather than substituting `Process::DEFAULT_NAMESPACE`
2483 // at the primitive.
2484 let mut p = pool_named("attest-pool");
2485 p.metadata.namespace = None;
2486 assert!(p.metadata.namespace.is_none(), "fixture invariant");
2487 assert_eq!(p.owned_namespace_or_empty(), String::new());
2488 }
2489
2490 #[test]
2491 fn owned_namespace_or_empty_returns_owned_string_when_slot_is_populated() {
2492 let mut p = pool_named("attest-pool");
2493 p.metadata.namespace = Some("ephemeral-pools".into());
2494 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2495 }
2496
2497 #[test]
2498 fn owned_namespace_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2499 // Corner between `None` (missing slot) and `Some(String::new())`
2500 // (populated slot containing the empty string): the primitive
2501 // MUST fold both to the same `""` byte-shape so a downstream
2502 // `AllocationRef.namespace ==` comparator at
2503 // `resolve_pool` sees ONE "unset namespace" bucket regardless
2504 // of which shape the K8s API server materialized. Byte-
2505 // identical to what the pre-lift `.clone().unwrap_or_default()`
2506 // chain produced.
2507 let mut p = pool_named("attest-pool");
2508 p.metadata.namespace = Some(String::new());
2509 assert_eq!(p.owned_namespace_or_empty(), String::new());
2510 assert!(p.owned_namespace_or_empty().is_empty());
2511 }
2512
2513 #[test]
2514 fn owned_namespace_or_empty_is_a_pure_projection() {
2515 // Consecutive calls return byte-identical Strings — no cached
2516 // state, no mutation on the `EphemeralPool` between calls.
2517 // Peer to the sibling `owned_name_or_empty_is_a_pure_projection`
2518 // pin in this module and to `is_being_deleted_is_a_pure_projection`
2519 // on the same CRD; all three bind the pure-projection
2520 // discipline on the ONE substrate accessor per metadata slot.
2521 let mut p = pool_named("attest-pool");
2522 p.metadata.namespace = Some("ephemeral-pools".into());
2523 assert_eq!(p.owned_namespace_or_empty(), p.owned_namespace_or_empty());
2524 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2525 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
2526 }
2527
2528 #[test]
2529 fn owned_namespace_or_empty_matches_pre_lift_chain_verbatim() {
2530 // Byte-identical parity with the two hand-authored
2531 // `.metadata.namespace.clone().unwrap_or_default()` chains
2532 // the primitive replaces in `tatara-pool-reconciler::
2533 // allocation_decide::AllocationConvergenceCtx::observe`
2534 // (matched-pool `AllocationRef.namespace` seed) and in the
2535 // sibling composition pin
2536 // `allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`.
2537 // Runs across the FULL corner set of the metadata.namespace
2538 // slot: absent, present-with-value, present-with-empty-string.
2539 // A regression that inserted a normalization step at the
2540 // primitive the pre-lift chain does NOT apply — or vice versa —
2541 // surfaces here rather than as silent drift between the two
2542 // owned-form callsites and the ONE substrate owner they now
2543 // route through.
2544 let cases: [(Option<String>, &str); 3] = [
2545 (None, ""),
2546 (Some("ephemeral-pools".into()), "ephemeral-pools"),
2547 (Some(String::new()), ""),
2548 ];
2549 for (slot, expected) in cases {
2550 let mut p = pool_named("attest-pool");
2551 p.metadata.namespace = slot.clone();
2552 let pre_lift = p.metadata.namespace.clone().unwrap_or_default();
2553 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2554 assert_eq!(p.owned_namespace_or_empty(), pre_lift);
2555 assert_eq!(p.owned_namespace_or_empty().as_str(), expected);
2556 }
2557 }
2558
2559 #[test]
2560 fn owned_namespace_or_empty_composes_with_owned_name_or_empty_on_paired_slot_axis() {
2561 // Paired-axis coherence pin: the two owned-form primitives on
2562 // the pool CRD's `metadata.name` + `metadata.namespace` slots
2563 // share the SAME empty-string sentinel on the missing corner,
2564 // so a caller that composes both halves into an
2565 // `AllocationRef` (as `AllocationConvergenceCtx::observe`
2566 // does) never sees a mixed-fallback pair (one `""`, the
2567 // other `"default"`) as a side effect of one slot being
2568 // absent. A regression that skewed either primitive's
2569 // fallback would surface here rather than as silent operator-
2570 // facing skew between the paired halves of the SAME
2571 // `AllocationRef` seed.
2572 let mut p = pool_named("attest-pool");
2573 p.metadata.namespace = None;
2574 p.metadata.name = None;
2575 assert_eq!(p.owned_name_or_empty(), p.owned_namespace_or_empty());
2576 assert_eq!(p.owned_name_or_empty(), String::new());
2577 assert_eq!(p.owned_namespace_or_empty(), String::new());
2578 }
2579
2580 #[test]
2581 fn owned_namespace_or_empty_does_not_default_to_process_default_namespace() {
2582 // Deliberate-empty-sentinel pin: the primitive's fallback is
2583 // `""`, NOT `crate::crd::Process::DEFAULT_NAMESPACE`. The
2584 // sole downstream consumer (`AllocationConvergenceCtx::observe`)
2585 // feeds the produced value into `AllocationRef.namespace`,
2586 // which is then matched byte-identically against
2587 // `spec.pool_ref.namespace` at `resolve_pool`. A silent
2588 // substitution of `"default"` at this primitive would alias
2589 // every namespace-absent pool to the `"default"` bucket at
2590 // the matcher, hiding the missing-slot corner from an
2591 // operator who explicitly authored an allocation against a
2592 // namespace-unset pool. Pinned so a future "helpful"
2593 // canonicalization step lands as a compiler-visible failure
2594 // here rather than as silent operator-facing skew at the
2595 // matched-pool seed.
2596 let mut p = pool_named("attest-pool");
2597 p.metadata.namespace = None;
2598 assert_ne!(
2599 p.owned_namespace_or_empty(),
2600 crate::crd::Process::DEFAULT_NAMESPACE
2601 );
2602 assert_eq!(p.owned_namespace_or_empty(), "");
2603 }
2604
2605 // ─── AllocationRef::new substrate pins ────────────────────────────
2606 //
2607 // Pins the substrate constructor for [`AllocationRef`] — the
2608 // ONE-liner composer that lifts the paired
2609 // `AllocationRef { name, namespace }` struct-literal every
2610 // downstream consumer restated by hand pre-lift at FOUR production
2611 // sites (2 × controller_allocation.rs assignedProcess seeds, 1 ×
2612 // allocation_decide.rs pool_ref seed, 1 × allocation_factory.rs
2613 // pool_ref seed) onto ONE substrate owner on `AllocationRef`.
2614 // Fail-before-pass-after granularity: `AllocationRef::new` did not
2615 // exist pre-lift; the compiler cannot resolve the name until the
2616 // impl block above is in place, so a rollback of the primitive
2617 // breaks this whole module.
2618
2619 #[test]
2620 fn allocation_ref_new_composes_owned_string_pair_verbatim() {
2621 // Happy-path pin: the constructor materializes an
2622 // `AllocationRef { name: <name>, namespace: <namespace> }`
2623 // byte-identical to the pre-lift struct literal every consumer
2624 // spelled. A regression that dropped either slot (e.g. an
2625 // erroneous `..Default::default()` on a shape that never had
2626 // a Default derive) surfaces here rather than as silent slot
2627 // loss downstream at the assignedProcess / bound_pool /
2628 // matched_pool / spec.pool_ref sinks.
2629 let r = AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2630 assert_eq!(r.name, "pr-42-demo");
2631 assert_eq!(r.namespace, "ephemeral-pools");
2632 }
2633
2634 #[test]
2635 fn allocation_ref_new_matches_pre_lift_struct_literal_verbatim() {
2636 // Byte-identical parity pin: the substrate constructor and the
2637 // hand-authored struct literal produce equal `AllocationRef`
2638 // values on every provenance the FOUR pre-lift sites carried
2639 // (owned `String` from an owned-form projection; `&str`
2640 // promoted through `.to_string()`). A regression that inserted
2641 // a normalization step at the primitive the pre-lift literal
2642 // does NOT apply — or vice versa — surfaces here rather than
2643 // as silent drift between the four consumers and the ONE
2644 // substrate owner they now route through.
2645 let owned_name = String::from("pr-42-demo");
2646 let owned_ns = String::from("ephemeral-pools");
2647 let lifted = AllocationRef::new(owned_name.clone(), owned_ns.clone());
2648 let pre_lift = AllocationRef {
2649 name: owned_name,
2650 namespace: owned_ns,
2651 };
2652 assert_eq!(lifted, pre_lift);
2653 }
2654
2655 #[test]
2656 fn allocation_ref_new_accepts_str_provenance_via_into_string() {
2657 // `Into<String>` provenance-closure pin: the primitive accepts
2658 // every provenance the pre-lift sites carried. The
2659 // controller_allocation.rs assignedProcess seeds passed owned
2660 // `String` values (a moved `member_process_name` +
2661 // `ns.clone()`); the allocation_factory.rs pool_ref seed
2662 // passed `&str` (`n.to_string()` / `namespace.to_string()`).
2663 // Both provenances produce byte-identical output. A future
2664 // refactor of the constructor signature that demanded owned
2665 // `String` at author sites (dropping `impl Into<String>`)
2666 // would force `.to_string()` back at the FOUR call sites — the
2667 // pin fences that regression at ONE place.
2668 let from_str = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2669 let from_string =
2670 AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2671 assert_eq!(from_str, from_string);
2672 // Mixed provenance is also load-bearing: the allocation_decide.rs
2673 // matched_pool seed pairs an owned `String` (from
2674 // `EphemeralPool::owned_name_or_empty()`) with a hand-authored
2675 // `.clone().unwrap_or_default()` — also `String`. The
2676 // controller_allocation.rs paths pair a moved `String` name
2677 // with a `.clone()`-ed `ns: String`. Verify (owned, borrow)
2678 // and (borrow, owned) both compose to the same shape as
2679 // (owned, owned) / (borrow, borrow).
2680 let mixed_a = AllocationRef::new(String::from("pr-42-demo"), "ephemeral-pools");
2681 let mixed_b = AllocationRef::new("pr-42-demo", String::from("ephemeral-pools"));
2682 assert_eq!(from_str, mixed_a);
2683 assert_eq!(from_str, mixed_b);
2684 }
2685
2686 #[test]
2687 fn allocation_ref_new_positional_axis_order_pinned_name_first_namespace_second() {
2688 // Axis-order pin: name is the FIRST positional argument;
2689 // namespace is the SECOND. Reversing the pair at the
2690 // constructor is the exact regression this pin fences — the
2691 // FOUR pre-lift sites all spelled `name` before `namespace`
2692 // (matching the struct definition's field order in
2693 // `pub struct AllocationRef { pub name, pub namespace }`)
2694 // and the wire-format serde output `{ "name": "...",
2695 // "namespace": "..." }` reflects that order. A slot swap at
2696 // the primitive would surface here rather than as silent
2697 // `<namespace>/<name>` inversion at every downstream
2698 // qualified-ref composer that reads `{ref.name}/{ref.namespace}`
2699 // as an audit-log key.
2700 let r = AllocationRef::new("alpha-name", "beta-namespace");
2701 assert_eq!(r.name, "alpha-name");
2702 assert_eq!(r.namespace, "beta-namespace");
2703 assert_ne!(r.name, "beta-namespace");
2704 assert_ne!(r.namespace, "alpha-name");
2705 }
2706
2707 #[test]
2708 fn allocation_ref_new_preserves_empty_string_verbatim() {
2709 // Empty-string sentinel pin: the constructor is pure — it does
2710 // NOT canonicalize empty inputs (does NOT default an empty
2711 // namespace to `"default"`; does NOT reject an empty name).
2712 // Preserves the pre-lift shape the allocation_decide.rs
2713 // matched_pool seed relied on: when the pool's metadata.namespace
2714 // is absent, `.clone().unwrap_or_default()` yields the empty
2715 // string, and the AllocationRef's namespace slot carries that
2716 // empty string verbatim to the downstream `bound_pool` sink.
2717 // A future canonicalization pass (e.g. defaulting to
2718 // `Process::DEFAULT_NAMESPACE`) MUST land here, not at the
2719 // primitive body silently, so the pre-lift consumers' empty-
2720 // sentinel semantics are the visible contract of the new
2721 // constructor.
2722 let r = AllocationRef::new("", "");
2723 assert_eq!(r.name, "");
2724 assert_eq!(r.namespace, "");
2725 let mixed = AllocationRef::new("pr-42-demo", "");
2726 assert_eq!(mixed.name, "pr-42-demo");
2727 assert_eq!(mixed.namespace, "");
2728 }
2729
2730 #[test]
2731 fn allocation_ref_new_composes_with_owned_name_or_empty_pool_projection() {
2732 // Composition pin: the constructor composes with the paired
2733 // substrate primitives [`EphemeralPool::owned_name_or_empty`]
2734 // + [`EphemeralPool::owned_namespace_or_empty`] at the
2735 // allocation_decide.rs pool_ref seed — the same primitive
2736 // family the pool CRD opened for both halves of the
2737 // `AllocationRef { name, namespace }` struct literal. The
2738 // composed pair carries an owned `String` name half (from
2739 // `pool.owned_name_or_empty()`) and an owned `String`
2740 // namespace half (from `pool.owned_namespace_or_empty()`) —
2741 // no pre-lift chain remains. A regression that broke the
2742 // primitive family's `impl Into<String>` acceptance of an
2743 // owned `String` return type would surface here rather than
2744 // as silent build failure at the pool-reconciler matched_pool
2745 // seed.
2746 let pool = pool_named("attest-pool");
2747 let r = AllocationRef::new(pool.owned_name_or_empty(), pool.owned_namespace_or_empty());
2748 assert_eq!(r.name, "attest-pool");
2749 assert_eq!(r.namespace, pool.owned_namespace_or_empty());
2750 }
2751
2752 #[test]
2753 fn allocation_ref_new_returns_wire_format_serialization_verbatim() {
2754 // Wire-format pin: the constructor produces an
2755 // [`AllocationRef`] whose serde `rename_all = "camelCase"`
2756 // serialization is byte-identical to the pre-lift struct
2757 // literal's serialization. The `bound_pool` and
2758 // `assignedProcess` slots on `AllocationStatus` (and the
2759 // `poolRef` slot on `AllocationSpec`) all round-trip through
2760 // this shape — the pin fences a regression that added a
2761 // private field or a `#[serde(skip)]` accidentally.
2762 let r = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2763 let yaml = serde_yaml::to_string(&r).expect("AllocationRef serializes to yaml");
2764 assert!(yaml.contains("name: pr-42-demo"), "{yaml}");
2765 assert!(yaml.contains("namespace: ephemeral-pools"), "{yaml}");
2766 let back: AllocationRef =
2767 serde_yaml::from_str(&yaml).expect("AllocationRef round-trips through yaml");
2768 assert_eq!(back, r);
2769 }
2770
2771 fn member(state: MemberState) -> PoolMember {
2772 PoolMember {
2773 process_name: "m".into(),
2774 state,
2775 entered_state_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
2776 allocation_ref: None,
2777 }
2778 }
2779
2780 #[test]
2781 fn state_count_fanout_returns_all_zeros_on_empty_slice() {
2782 // Zero-length pin: the empty-members corner produces a
2783 // 4-tuple of zero counters, matching the pre-lift
2784 // `count_state` fanout's four `.iter().filter(...).count()`
2785 // calls each returning 0 on an empty iterator.
2786 assert_eq!(PoolMember::state_count_fanout(&[]), (0, 0, 0, 0));
2787 }
2788
2789 #[test]
2790 fn state_count_fanout_partitions_variants_into_correct_slots() {
2791 // Positional-axis pin: the returned 4-tuple's slot order
2792 // matches the four `PoolStatus` counter slots in declaration
2793 // order — `(ready, allocated, spawning, returning)`. A
2794 // regression that swapped two slots (e.g., `ready` ↔
2795 // `spawning`) surfaces here rather than as an operator-facing
2796 // scale-out oscillation at the pool reconciler.
2797 let members = vec![
2798 member(MemberState::Free),
2799 member(MemberState::Free),
2800 member(MemberState::Allocated),
2801 member(MemberState::Spawning),
2802 member(MemberState::Spawning),
2803 member(MemberState::Spawning),
2804 member(MemberState::Returning),
2805 ];
2806 assert_eq!(PoolMember::state_count_fanout(&members), (2, 1, 3, 1));
2807 }
2808
2809 #[test]
2810 fn state_count_fanout_excludes_failed_from_every_counter() {
2811 // Closed-set pin: no `PoolStatus` slot counts `Failed` members
2812 // (they surface via `PoolPhase::Degraded` instead of a status
2813 // counter). This test fences a regression that let a `Failed`
2814 // member drift into one of the four counters and inflate the
2815 // operator-visible ready/allocated/spawning/returning fanout.
2816 let members = vec![
2817 member(MemberState::Failed),
2818 member(MemberState::Failed),
2819 member(MemberState::Failed),
2820 ];
2821 assert_eq!(PoolMember::state_count_fanout(&members), (0, 0, 0, 0));
2822
2823 // Mixed with a Free member: the Free member is counted, the
2824 // Failed members are not.
2825 let mixed = vec![
2826 member(MemberState::Free),
2827 member(MemberState::Failed),
2828 member(MemberState::Failed),
2829 ];
2830 assert_eq!(PoolMember::state_count_fanout(&mixed), (1, 0, 0, 0));
2831 }
2832
2833 #[test]
2834 fn state_count_fanout_matches_pre_lift_count_state_helper_verbatim() {
2835 // Parity pin: for every possible members list, the 4-tuple
2836 // returned by the substrate primitive matches the pre-lift
2837 // `count_state(&members, MemberState::<slot>)` fanout that
2838 // pool-reconciler restated at both status-patch sites. The
2839 // pre-lift helper was
2840 // ```rust,ignore
2841 // fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
2842 // members.iter().filter(|m| m.state == target).count() as u32
2843 // }
2844 // ```
2845 // — re-implemented inline here as an oracle.
2846 fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
2847 members.iter().filter(|m| m.state == target).count() as u32
2848 }
2849 let members = vec![
2850 member(MemberState::Free),
2851 member(MemberState::Allocated),
2852 member(MemberState::Allocated),
2853 member(MemberState::Spawning),
2854 member(MemberState::Returning),
2855 member(MemberState::Returning),
2856 member(MemberState::Failed),
2857 ];
2858 let (ready, allocated, spawning, returning) = PoolMember::state_count_fanout(&members);
2859 assert_eq!(ready, count_state(&members, MemberState::Free));
2860 assert_eq!(allocated, count_state(&members, MemberState::Allocated));
2861 assert_eq!(spawning, count_state(&members, MemberState::Spawning));
2862 assert_eq!(returning, count_state(&members, MemberState::Returning));
2863 }
2864
2865 #[test]
2866 fn pool_status_observed_composes_pre_lift_status_seed_verbatim() {
2867 // Composition pin: the substrate constructor produces a
2868 // `PoolStatus` structurally equal to the pre-lift 11-line
2869 // struct literal both pool-reconciler status-patch sites
2870 // stamped by hand. Any drift in the defaults (`message`,
2871 // `conditions`) or in the counter fanout surfaces here.
2872 let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
2873 let members = vec![
2874 member(MemberState::Free),
2875 member(MemberState::Allocated),
2876 member(MemberState::Spawning),
2877 member(MemberState::Returning),
2878 member(MemberState::Failed),
2879 ];
2880 let member_count = members.len();
2881 let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
2882 assert_eq!(observed.phase, PoolPhase::Steady);
2883 assert_eq!(observed.phase_since, Some(now));
2884 assert_eq!(observed.ready_count, 1);
2885 assert_eq!(observed.allocated_count, 1);
2886 assert_eq!(observed.spawning_count, 1);
2887 assert_eq!(observed.returning_count, 1);
2888 assert_eq!(observed.members.len(), member_count);
2889 assert!(observed.message.is_none());
2890 assert!(observed.conditions.is_empty());
2891 }
2892
2893 #[test]
2894 fn pool_status_observed_moves_members_by_value_without_extra_clone() {
2895 // Ownership pin: the constructor consumes the members Vec by
2896 // value rather than borrowing + cloning internally. Both pre-
2897 // lift sites called `.clone()` on their `members` binding for
2898 // the struct-literal `members:` slot; the substrate lift keeps
2899 // the same one-clone bound at the caller (or a straight move
2900 // if the caller no longer needs the local `members` binding
2901 // after the seed) rather than accidentally cloning twice.
2902 let members = vec![member(MemberState::Free), member(MemberState::Spawning)];
2903 let now = DateTime::<Utc>::from_timestamp(0, 0).unwrap();
2904 let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
2905 assert_eq!(observed.members.len(), 2);
2906 }
2907}