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