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 /// The namespaced-CRD constructor composer on the `EphemeralPool`
856 /// axis: forwards `(name, spec)` to the kube-derived
857 /// [`Self::new`] constructor + stamps `metadata.namespace` with
858 /// the caller-supplied slot in ONE step. The ONE-liner collapse
859 /// of the paired `let mut p = EphemeralPool::new(<name>, <spec>);
860 /// p.meta_mut().namespace = Some(<ns>.into());` incantation every
861 /// pool-side test fixture restated by hand pre-lift.
862 ///
863 /// Pre-lift the 2-line construct-then-set-namespace chain was
864 /// hand-authored at FOUR sites past the ★★ PRIME-DIRECTIVE ≥ 2
865 /// duplication threshold in `tatara-pool-reconciler`, all
866 /// composing a namespaced `EphemeralPool` fixture from a `name`
867 /// slot and a `PoolSpec`:
868 /// * `router::pool` — the selector-routing test fixture pinned
869 /// to `"ephemeral-pools"`.
870 /// * `pool_decide::pool` — the desired-count-loop test fixture
871 /// pinned to `"pools"`.
872 /// * `desired::pool` — the replacement-policy test fixture
873 /// pinned to `"pools"`.
874 /// * `allocation_decide::pool` — the allocation-decision test
875 /// fixture pinned to the caller-supplied `ns` slot.
876 ///
877 /// All four sites walked the SAME 2-line chain and all four
878 /// wanted the `EphemeralPool` back with `metadata.namespace`
879 /// stamped as `Some(<ns>.into())`. Post-lift each callsite reads
880 /// `EphemeralPool::new_in(<name>, <ns>, <spec>)` and the produced
881 /// value feeds the same downstream reconciler-input `Vec<
882 /// EphemeralPool>` unchanged.
883 ///
884 /// The `impl Into<String>` at the `namespace` slot matches the
885 /// sibling `impl Into<String>`-widening discipline the workspace's
886 /// other namespaced-CRD-adjacent composers walk
887 /// ([`crate::pool::PoolMember::unallocated`] on the
888 /// `process_name` slot, [`crate::pool::AllocationRef::new`] on the
889 /// `(name, namespace)` slot pair, [`crate::allocation::
890 /// Requestor::kind_only`] on the `kind` slot) and accepts BOTH
891 /// `&'static str` (the majority pre-lift caller shape) AND owned
892 /// `String` at the SAME signature.
893 ///
894 /// Peer to [`crate::allocation::EphemeralAllocation::new_in`] on
895 /// the sister `EphemeralAllocation` CRD — the two primitives
896 /// partition the namespaced-CRD-constructor family axis for the
897 /// two pool-adjacent CRDs the workspace stamps at reconciler
898 /// fixture / GitHub-webhook-emitter time. A future normalization
899 /// (a per-fleet virtual-cluster prefix rewrite on the `namespace`
900 /// slot, a per-cluster canonical case-fold pass, a
901 /// `generateName` fallback on the `name` slot, an operator-scoped
902 /// default namespace for cluster-local test rigs, an audit-tag
903 /// stamped on every fixture-emitted CRD for post-hoc grep
904 /// discipline) lands at ONE primitive body per CRD and every
905 /// downstream fixture consumer inherits the upgrade mechanically.
906 ///
907 /// `#[must_use]` on the return keeps a caller from composing the
908 /// namespaced value and dropping it un-passed to a reconciler-
909 /// input slot or an assertion helper.
910 ///
911 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
912 /// the 2-line construct-then-set-namespace chain recurred at
913 /// FOUR hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
914 /// duplication trigger, spanning one crate but four modules, and
915 /// is lifted to ONE substrate owner here). THEORY.md §II.1
916 /// invariant 5 (composition preserves proofs — the pins below
917 /// bind the (name-slot → metadata.name, ns-slot → metadata.
918 /// namespace, spec-slot → spec) slot-projection triple + the
919 /// byte-identical parity with the pre-lift 2-line chain across
920 /// the two representative `impl Into<String>` value shapes
921 /// (`&'static str` and owned `String`) + the sibling-composer
922 /// coherence with [`Self::new`]).
923 #[must_use]
924 pub fn new_in(name: &str, namespace: impl Into<String>, spec: PoolSpec) -> Self {
925 // Routes through the ONE substrate owner of the
926 // `metadata.namespace` stamp — the [`crate::PlacedInNamespace`]
927 // blanket-impl trait over `kube::Resource<DynamicType = ()>`.
928 // Byte-identical to the pre-lift 3-line body
929 // (`Self::new(name, spec); metadata.namespace = Some(namespace
930 // .into())`); the trait-forwarding form collapses the mutation
931 // duplication with the sibling per-CRD composer
932 // [`crate::allocation::EphemeralAllocation::new_in`] and with
933 // the render-fixture site on `Process` that has no per-CRD
934 // `new_in` sibling.
935 use crate::PlacedInNamespace;
936 Self::new(name, spec).in_namespace(namespace)
937 }
938
939 /// Observed pool phase from live member observations — the pure
940 /// typed projection every pool-reconciler status-patch site needs
941 /// before it stamps [`PoolStatus`] on the wire.
942 ///
943 /// # Why it exists
944 ///
945 /// Pre-lift the phase computation lived at
946 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
947 /// as a private free function — a repo-local closed-set match over
948 /// the (tombstone-first, empty-members, min-floor, supply-vs-desired)
949 /// gate ladder. Every downstream that wanted to know a pool's
950 /// observed phase from its members had to reach into the
951 /// reconciler crate for the helper, so the projection stayed a
952 /// controller-internal detail even though `EphemeralPool` +
953 /// `PoolMember` + `PoolPhase` all live in this substrate crate.
954 /// Post-lift the projection lives at the ONE typed accessor on
955 /// `EphemeralPool` — the natural owner, since the four gate slots
956 /// the ladder reads (`is_being_deleted()`, `spec.desired_size`,
957 /// `spec.min_size`, and the members supply computed via
958 /// [`crate::pool::MemberState::counts_toward_supply`]) all belong
959 /// to `EphemeralPool` or to the shared substrate. Any future
960 /// consumer (a feira `pool status --phase` command, an MCP tool
961 /// that renders pool health, a dashboard SSE feed, a peer
962 /// controller that mirrors pool state into an external system)
963 /// reads the phase through this ONE substrate method rather than
964 /// re-implementing the gate ladder against the pre-lift
965 /// controller-internal shape.
966 ///
967 /// # Gate ladder
968 ///
969 /// The ladder walks in strict priority order — the first gate
970 /// that fires returns:
971 ///
972 /// 1. **Tombstone-first** — `is_being_deleted()` → `Draining`.
973 /// Keeps the reported phase honest during the finalizer drain
974 /// so operators reading `kubectl get ephemeralpools` see the
975 /// tombstone-present state as `Draining`, not as a stale
976 /// `Steady` derived from the pre-tombstone supply arithmetic.
977 /// 2. **Empty-members** — `members.is_empty()` → `Initializing`.
978 /// A pool with zero members is either fresh (never had a
979 /// spawn) or fully reaped without the tombstone; either way
980 /// the supply arithmetic below has no signal to work with,
981 /// so the ladder short-circuits.
982 /// 3. **Min-floor** — `spec.min_size > 0 && supply < spec.min_size`
983 /// → `Degraded`. Hard floor breach: the pool has members but
984 /// not enough Free/Spawning capacity to serve requestors
985 /// without dipping below the operator-declared floor.
986 /// 4. **Supply-vs-desired** — `supply < spec.desired_size` →
987 /// `ScalingUp`; `supply > spec.desired_size` → `ScalingDown`.
988 /// The standard replenishment arithmetic that the reconciler's
989 /// convergence loop drives toward zero.
990 /// 5. **Steady** — the terminal arm. Supply matches desired, no
991 /// scaling pending, no tombstone, no floor breach.
992 ///
993 /// The supply computation rides through the closed-set predicate
994 /// [`crate::pool::MemberState::counts_toward_supply`] — a
995 /// `MemberState` variant that should also count toward supply
996 /// (e.g. a "Warming" state between Spawning and Free) lands at
997 /// ONE predicate arm and this method inherits the new bucketing
998 /// automatically. The
999 /// `member_state_failed_implies_no_supply` contract test on
1000 /// [`crate::pool::MemberState`] pins that a `Failed` member can
1001 /// never inflate this count and pollute the ladder's gate
1002 /// decisions.
1003 ///
1004 /// # Invariants
1005 ///
1006 /// - **Priority order:** the ladder walks tombstone → empty →
1007 /// floor → supply → steady; a regression that reordered any two
1008 /// gates would surface at `tests::observed_phase_from_*` as a
1009 /// truth-table mismatch across the gate corners.
1010 /// - **Pure projection:** two consecutive calls on the same
1011 /// `(pool, members)` pair return the same `PoolPhase` — no
1012 /// hidden state, no per-tick clock read (the tombstone probe
1013 /// is a metadata slot presence check, not a wall-clock
1014 /// comparison).
1015 /// - **Byte-identical to the pre-lift chain:** the ladder
1016 /// collapses to the same `PoolPhase` the reconciler's private
1017 /// `pool_phase_from_members` free function produced pre-lift
1018 /// at every gate corner — pinned by
1019 /// `tests::observed_phase_from_matches_pre_lift_reconciler_chain`.
1020 ///
1021 /// Sibling to the peer typed-projection primitives on
1022 /// `EphemeralPool` ([`Self::is_being_deleted`],
1023 /// [`Self::name_or_empty`], [`Self::owned_name_or_empty`],
1024 /// [`Self::owned_namespace_or_empty`]) — closes the corner the
1025 /// pool-side family previously left open on the (observation →
1026 /// derived-phase) axis. Peer to
1027 /// [`crate::pool::PoolStatus::observed_from`] on the (pure typed
1028 /// projection, compound-composer) axis; the peer chains this
1029 /// projection with the wall-clock-anchored
1030 /// [`crate::pool::PoolStatus::observed_now`] stamp so both
1031 /// pool-reconciler status-patch sites route the observation
1032 /// through ONE substrate composer rather than through the pre-
1033 /// lift 3-line (phase-compute + observed_now + merge_status)
1034 /// chain.
1035 ///
1036 /// # `#[must_use]`
1037 ///
1038 /// The returned [`PoolPhase`] is a pure typed projection; every
1039 /// consumer feeds it into a downstream status-patch composer or
1040 /// operator-facing diagnostic. Dropping the return means the
1041 /// projection was computed for no observable reason.
1042 ///
1043 /// Theory anchor: THEORY.md §II.1 invariant 3 (typed exit — the
1044 /// gate ladder's five arms partition the observed-phase space
1045 /// exhaustively and the closed-set match at each arm keeps the
1046 /// exhaustiveness under compiler control). THEORY.md §III (the
1047 /// typescape — this projection is a typed accessor on
1048 /// `EphemeralPool`, coherent with the workspace-wide typed
1049 /// projection family the pool-side primitives already anchor).
1050 #[must_use]
1051 pub fn observed_phase_from(&self, members: &[PoolMember]) -> PoolPhase {
1052 if self.is_being_deleted() {
1053 return PoolPhase::Draining;
1054 }
1055 if members.is_empty() {
1056 return PoolPhase::Initializing;
1057 }
1058 let supply = members
1059 .iter()
1060 .filter(|m| m.state.counts_toward_supply())
1061 .count() as u32;
1062 if self.spec.min_size > 0 && supply < self.spec.min_size {
1063 return PoolPhase::Degraded;
1064 }
1065 let want = self.spec.desired_size;
1066 if supply < want {
1067 return PoolPhase::ScalingUp;
1068 }
1069 if supply > want {
1070 return PoolPhase::ScalingDown;
1071 }
1072 PoolPhase::Steady
1073 }
1074}
1075
1076/// What the pool reconciler does when a member reaches `Failed`.
1077///
1078/// Sibling closed-set lifts on the same `tatara-process` axis:
1079/// [`crate::compliance::VerificationPhase::ALL`],
1080/// [`crate::signal::SighupStrategy::ALL`],
1081/// [`crate::spec::MustReachPhase::ALL`],
1082/// [`crate::intent::WorkloadKind::ALL`],
1083/// [`crate::export::ReportFormat::ALL`],
1084/// [`crate::encapsulates::EncapsulationMode::ALL`],
1085/// [`crate::export::ExportTrigger::ALL`],
1086/// [`crate::lifetime::TeardownPolicy::ALL`],
1087/// [`crate::boundary::ConditionKind::ALL`],
1088/// [`crate::lifetime::LifetimeKind::ALL`],
1089/// [`crate::intent::IntentKind::ALL`],
1090/// [`crate::phase::ProcessPhase::ALL`],
1091/// [`crate::signal::ProcessSignal::ALL`].
1092#[derive(
1093 Clone,
1094 Copy,
1095 Debug,
1096 Default,
1097 Serialize,
1098 Deserialize,
1099 JsonSchema,
1100 PartialEq,
1101 Eq,
1102 Hash,
1103 tatara_closed_set::DeriveClosedSet,
1104)]
1105#[serde(rename_all = "PascalCase")]
1106#[closed_set(via = "as_str", generate_unknown, display)]
1107pub enum ReplacementPolicy {
1108 /// **Default** — Failed member is reaped + replaced immediately
1109 /// (pool stays at `desired` count). Most production-like.
1110 #[default]
1111 ReplaceImmediate,
1112 /// Failed member stays for inspection; pool runs short until the
1113 /// operator manually reaps it. Useful for debugging.
1114 HoldFailed,
1115 /// Failed member triggers pool-wide pause: `desired` is
1116 /// effectively 0 until the operator manually resumes via a
1117 /// pool-status patch. Used for "halt on any failure" workflows.
1118 PausePool,
1119}
1120
1121impl ReplacementPolicy {
1122 /// The closed set of replacement policies — single source of truth
1123 /// that drives the `as_str` / Display / `FromStr` triad and the
1124 /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
1125 /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
1126 /// predicate arm per projection — exhaustively checked by the
1127 /// compiler (the `[Self; 3]` array literal forces the arity) and by
1128 /// the predicate-pair injectivity test below (a new variant must
1129 /// land in its own (replaces_failed, pauses_on_failure) bucket or
1130 /// the author has to extend the consumer dispatch in
1131 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
1132 pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
1133
1134 /// Canonical PascalCase wire-format projection — matches the serde
1135 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
1136 /// enumeration the pool reconciler stamps on the
1137 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
1138 /// `replacement_policy_as_str_matches_serde` so a variant rename
1139 /// can't drift between the typed surface, the CRD enum, the YAML
1140 /// wire format AND the operator-facing diagnostic (the
1141 /// `desired.rs` Pause reason composes `policy={policy}` via
1142 /// Display, not a hard-coded `"PausePool"` literal that would
1143 /// silently rot).
1144 pub const fn as_str(self) -> &'static str {
1145 match self {
1146 Self::ReplaceImmediate => "ReplaceImmediate",
1147 Self::HoldFailed => "HoldFailed",
1148 Self::PausePool => "PausePool",
1149 }
1150 }
1151
1152 /// Should the pool auto-spawn a replacement for a Failed member?
1153 /// Closed-set match (not `matches!`) so a future variant triggers
1154 /// the compiler's exhaustiveness check at this site rather than
1155 /// silently defaulting to `false`. Paired with
1156 /// `pauses_on_failure` they form the two-axis projection
1157 /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
1158 /// pattern-match against — `replaces_failed` true ⇒ emit
1159 /// `ReapFailed` per failure; `pauses_on_failure` true with any
1160 /// failure ⇒ emit `Pause` and short-circuit. The pair is
1161 /// `(true, false) | (false, false) | (false, true)` — pinned
1162 /// injective by `replacement_policy_predicate_pair_is_injective`.
1163 pub const fn replaces_failed(self) -> bool {
1164 match self {
1165 Self::ReplaceImmediate => true,
1166 Self::HoldFailed | Self::PausePool => false,
1167 }
1168 }
1169
1170 /// Should reaching Failed on any member pause the whole pool?
1171 /// See `replaces_failed` for the closed-match rationale + the
1172 /// predicate-pair contract.
1173 pub const fn pauses_on_failure(self) -> bool {
1174 match self {
1175 Self::PausePool => true,
1176 Self::ReplaceImmediate | Self::HoldFailed => false,
1177 }
1178 }
1179}
1180
1181// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
1182// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
1183// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
1184// declaration above. `label` delegates to the inherent
1185// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
1186// PascalCase wire-format projection stays load-bearing (matches the
1187// serde `rename_all = "PascalCase"` output AND the
1188// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
1189// emission verbatim) while generic `T: ClosedSet` consumers reach the
1190// STABLE workspace-wide name (`label`); Display delegates to the same
1191// inherent projection via `#[closed_set(display)]` so the
1192// `Pause` reason emitter's `policy={policy}` composition stays
1193// pinned on the closed-set algebra rather than on a hand-rolled
1194// `fmt::Display` block per implementor.
1195
1196// `pub struct UnknownReplacementPolicy(pub String)` is generated by
1197// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1198// on the enum declaration above. The auto-derived label
1199// `"replacement policy"` matches the prior hand-rolled
1200// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
1201// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
1202// [`crate::export::UnknownReportFormat`],
1203// [`crate::export::UnknownChannelKind`],
1204// [`crate::export::UnknownExportTrigger`],
1205// [`crate::lifetime::UnknownTeardownPolicy`],
1206// [`crate::boundary::UnknownConditionKind`], and
1207// [`crate::phase::UnknownPhase`].
1208
1209fn default_free_ttl() -> String {
1210 "24h".to_string()
1211}
1212fn default_max_allocation_ttl() -> String {
1213 "4h".to_string()
1214}
1215
1216/// `EphemeralPool.status` — observed pool population state.
1217#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1218#[serde(rename_all = "camelCase")]
1219pub struct PoolStatus {
1220 /// Pool lifecycle phase.
1221 #[serde(default)]
1222 pub phase: PoolPhase,
1223
1224 /// When the pool entered the current phase.
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1226 pub phase_since: Option<DateTime<Utc>>,
1227
1228 /// Number of members currently in `Free` state (ready for allocation).
1229 #[serde(default)]
1230 pub ready_count: u32,
1231
1232 /// Number of members currently `Allocated`.
1233 #[serde(default)]
1234 pub allocated_count: u32,
1235
1236 /// Number of members currently `Spawning` (not yet Attested).
1237 #[serde(default)]
1238 pub spawning_count: u32,
1239
1240 /// Number of members currently `Returning` (reset or replace
1241 /// in progress).
1242 #[serde(default)]
1243 pub returning_count: u32,
1244
1245 /// Member ledger — one entry per pool slot.
1246 #[serde(default)]
1247 pub members: Vec<PoolMember>,
1248
1249 /// Operator-visible message (e.g., "scaled down to floor").
1250 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub message: Option<String>,
1252
1253 /// Standard Kubernetes Conditions.
1254 #[serde(default)]
1255 pub conditions: Vec<PoolCondition>,
1256}
1257
1258/// One pool slot's state.
1259#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
1260#[serde(rename_all = "camelCase")]
1261pub struct PoolMember {
1262 /// `metadata.name` of the backing Process.
1263 pub process_name: String,
1264 /// Pool member's current slot state.
1265 pub state: MemberState,
1266 /// When the member entered the current state.
1267 pub entered_state_at: DateTime<Utc>,
1268 /// If allocated: the AllocationRef holding this slot.
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub allocation_ref: Option<AllocationRef>,
1271}
1272
1273impl PoolStatus {
1274 /// Substrate constructor for the observed [`PoolStatus`] seed:
1275 /// composes the `(phase, phase_since, ready/allocated/spawning
1276 /// /returning counts, members, message, conditions)` 9-slot record
1277 /// every pool-reconciler status-patch site restated by hand pre-
1278 /// lift. The four counters ride a SINGLE closed-set-driven fold
1279 /// over the members list (one pass rather than four independent
1280 /// filter-and-count passes); the `message` + `conditions` slots
1281 /// stay at their invariant `None` / `vec![]` defaults every pre-
1282 /// lift caller stamped verbatim, and `phase_since` is derived from
1283 /// the caller-supplied `now` timestamp so the constructor stays
1284 /// clock-injectable rather than implicitly reading wall time.
1285 ///
1286 /// Pre-lift the 11-line
1287 /// ```rust,ignore
1288 /// PoolStatus {
1289 /// phase,
1290 /// phase_since: Some(Utc::now()),
1291 /// ready_count: count_state(&members, MemberState::Free),
1292 /// allocated_count: count_state(&members, MemberState::Allocated),
1293 /// spawning_count: count_state(&members, MemberState::Spawning),
1294 /// returning_count: count_state(&members, MemberState::Returning),
1295 /// members: members.clone(),
1296 /// message: None,
1297 /// conditions: vec![],
1298 /// }
1299 /// ```
1300 /// incantation was hand-authored at TWO sites past the ★★ PRIME-
1301 /// DIRECTIVE ≥ 2 duplication threshold in
1302 /// `tatara-pool-reconciler::controller_pool::reconcile_inner`,
1303 /// both restating the same 4-slot count fanout + defaults:
1304 /// * The `desired > 0` path — status patch after the
1305 /// convergence-action loop when the operator drives the pool
1306 /// through the R11 desired-count invariant.
1307 /// * The legacy allocation-driven path (`desired == 0`) — status
1308 /// patch after the [`crate::pool::PoolDecision`] apply loop.
1309 ///
1310 /// Both sites walked the SAME 4-slot count fanout on the SAME
1311 /// four `MemberState` variants (Free/Allocated/Spawning/Returning)
1312 /// and stamped the SAME defaults (`message: None`, `conditions:
1313 /// vec![]`), even though the four counters walked the members list
1314 /// four independent times pre-lift when a single pass suffices.
1315 /// Post-lift both callers write
1316 /// `PoolStatus::observed(phase, members, Utc::now())` and share
1317 /// ONE substrate owner; a future counter slot (e.g., a
1318 /// `warming_count` for a `MemberState::Warming` variant between
1319 /// Spawning and Free) plugs into the fold at ONE match arm and
1320 /// both status-patch sites inherit the new slot mechanically.
1321 ///
1322 /// The `Failed` variant is deliberately absent from the fold — no
1323 /// `PoolStatus` slot counts failed members (they surface via
1324 /// `pool_phase_from_members`'s `PoolPhase::Degraded` transition
1325 /// instead), and the closed-set match on
1326 /// [`MemberState`] pins that a future variant which SHOULD count
1327 /// toward one of the four buckets triggers the compiler's
1328 /// exhaustiveness check at this fold rather than silently sinking
1329 /// into `Failed`'s no-op arm.
1330 ///
1331 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1332 /// the 11-line status-seed incantation recurred at two hand-
1333 /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
1334 /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
1335 /// invariant 5 (composition preserves proofs — the pins bind the
1336 /// 4-slot count fanout + the closed-set exhaustiveness on
1337 /// `MemberState` + the invariant defaults, so a regression that
1338 /// dropped a counter slot or swapped a variant surfaces at
1339 /// `tests::pool_status_observed_*` rather than as silent operator-
1340 /// facing skew between the two status-patch sites on the SAME
1341 /// pool).
1342 #[must_use]
1343 pub fn observed(phase: PoolPhase, members: Vec<PoolMember>, now: DateTime<Utc>) -> Self {
1344 let (ready_count, allocated_count, spawning_count, returning_count) =
1345 PoolMember::state_count_fanout(&members);
1346 Self {
1347 phase,
1348 phase_since: Some(now),
1349 ready_count,
1350 allocated_count,
1351 spawning_count,
1352 returning_count,
1353 members,
1354 message: None,
1355 conditions: vec![],
1356 }
1357 }
1358
1359 /// Wall-clock-anchored peer of [`Self::observed`] — the ONE
1360 /// substrate owner of the 4-arg `PoolStatus::observed(phase,
1361 /// members, Utc::now())` composition every pool-reconciler
1362 /// status-patch site that reads the wall clock at tick-time
1363 /// hand-authored pre-lift.
1364 ///
1365 /// # Why it exists
1366 ///
1367 /// Pre-lift the 4-arg `PoolStatus::observed(phase, members.clone(),
1368 /// chrono::Utc::now())` chain was hand-authored at TWO sites past the
1369 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold in
1370 /// `tatara-pool-reconciler::controller_pool::reconcile_inner`, each
1371 /// pairing the 3-arg [`Self::observed`] composer with a
1372 /// `chrono::Utc::now()` third argument at the status-patch stamp:
1373 ///
1374 /// * The `desired > 0` path — status patch after the
1375 /// convergence-action loop when the operator drives the pool
1376 /// through the R11 desired-count invariant.
1377 /// * The legacy allocation-driven path (`desired == 0`) — status
1378 /// patch after the [`crate::pool::PoolDecision`] apply loop.
1379 ///
1380 /// Both sites walked the SAME 4-arg call with the SAME
1381 /// `chrono::Utc::now()` third argument — the wall-clock projection
1382 /// had no per-callsite variation. Post-lift both consumers share ONE
1383 /// substrate owner for the wall-clock-at-tick projection; a future
1384 /// clock swap (a monotonic clock cross-check, a per-reconciler
1385 /// injected time source, a test-only override at the production
1386 /// callsite via feature flag) lands at ONE substrate function and
1387 /// every pool-reconciler status-patch site inherits the upgrade
1388 /// mechanically.
1389 ///
1390 /// The 3-arg [`Self::observed`] peer stays load-bearing for test
1391 /// callers — the injected-`now` shape is what unit tests use to
1392 /// drive the clock deterministically (every
1393 /// `PoolStatus::observed(phase, members, seeded_now)` in this
1394 /// module's own test suite reads that surface). This peer is
1395 /// production-only: pinning the wall-clock at the substrate site
1396 /// means no test can accidentally consume it without the
1397 /// deterministic-clock injection that makes the test meaningful.
1398 ///
1399 /// Sibling of
1400 /// [`crate::lifetime_clock::evaluate_now`] on the (typed
1401 /// pure-fn, wall-clock-anchored peer) axis — both primitives own
1402 /// the "read the wall clock at tick-time" projection on a peer
1403 /// clock-injectable primitive so the workspace's timed-decision
1404 /// family stays uniform across `EphemeralLifetime` TTL expiry and
1405 /// `PoolStatus` observed-state stamp.
1406 ///
1407 /// # Invariants
1408 ///
1409 /// - **Same shape:** returns the SAME [`PoolStatus`] the 3-arg
1410 /// [`Self::observed`] returns when passed `chrono::Utc::now()` as
1411 /// the third argument. This is a delegation, not a
1412 /// re-implementation.
1413 /// - **Wall-clock read once:** `Utc::now()` is called exactly ONCE
1414 /// per invocation, at the primitive's body, so a future consumer
1415 /// that chains two `observed_now` calls back-to-back still sees
1416 /// monotonic `now` reads (each call reads a fresh instant, not a
1417 /// cached one) — matches the pre-lift shape where each of the two
1418 /// status-patch sites computed its own `chrono::Utc::now()` at its
1419 /// own line.
1420 ///
1421 /// # `#[must_use]`
1422 ///
1423 /// Every consumer feeds the returned [`PoolStatus`] into
1424 /// `tatara_process::patch::merge_status(&pool_api, &name, &<status>)`
1425 /// or a peer status-patch call. Dropping the return means the
1426 /// observation composed for no observable reason — the attribute
1427 /// surfaces that as a warning at every call site.
1428 ///
1429 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1430 /// the 4-arg call with `chrono::Utc::now()` as the third argument
1431 /// recurred at 2 hand-authored sites past the ★★ PRIME-DIRECTIVE
1432 /// ≥ 2 duplication trigger, lifted onto the ONE workspace-wide
1433 /// substrate owner here). THEORY.md §II.1 invariant 5 (composition
1434 /// preserves proofs — the wall-clock projection lives at ONE site
1435 /// so a future clock swap reaches both consumers through one edit).
1436 #[must_use]
1437 pub fn observed_now(phase: PoolPhase, members: Vec<PoolMember>) -> Self {
1438 Self::observed(phase, members, Utc::now())
1439 }
1440
1441 /// Compound composer peer of [`Self::observed_now`] that derives
1442 /// the [`PoolPhase`] from `(pool, members)` via the typed
1443 /// projection [`crate::pool::EphemeralPool::observed_phase_from`]
1444 /// — the ONE substrate owner of the (phase-compute + observed_now
1445 /// wall-clock stamp) 2-link chain every pool-reconciler status-
1446 /// patch site walked pre-lift.
1447 ///
1448 /// # Why it exists
1449 ///
1450 /// Pre-lift the 2-link `let phase = pool_phase_from_members(&pool,
1451 /// &members); PoolStatus::observed_now(phase, members.clone())`
1452 /// chain was hand-authored at TWO sites past the ★★ PRIME-
1453 /// DIRECTIVE ≥ 2 duplication threshold in
1454 /// `tatara-pool-reconciler::controller_pool::reconcile_inner`,
1455 /// both keyed against the same `(pool, members)` observations:
1456 ///
1457 /// * The `desired > 0` path — status patch after the
1458 /// `apply_convergence_actions` walk when the operator drives
1459 /// the pool through the R11 desired-count invariant.
1460 /// * The legacy allocation-driven path (`desired == 0`) — status
1461 /// patch after the [`crate::pool::PoolDecision`] apply loop.
1462 ///
1463 /// Both sites walked the SAME 2-link chain — compute the observed
1464 /// phase from the (tombstone-first, empty, floor, supply-vs-
1465 /// desired) gate ladder against the borrowed `(pool, members)`
1466 /// pair, then hand the produced phase + owned members clone to
1467 /// the wall-clock-anchored [`Self::observed_now`] composer. Both
1468 /// keyed the SAME projection through a repo-internal free
1469 /// function (`pool_phase_from_members`) that shadowed the natural
1470 /// substrate owner. Post-lift each callsite reads
1471 /// `PoolStatus::observed_from(&pool, members.clone())` and the
1472 /// compose+dispatch sink lives at ONE substrate owner —
1473 /// [`crate::pool::EphemeralPool::observed_phase_from`] +
1474 /// [`Self::observed_now`] compose here, at the exact substrate
1475 /// site where the pool + status types both live.
1476 ///
1477 /// # Invariants
1478 ///
1479 /// - **Same shape:** returns the SAME [`PoolStatus`] the 2-link
1480 /// chain `observed_now(pool.observed_phase_from(&members),
1481 /// members)` returns. This is a delegation, not a re-
1482 /// implementation — the underlying wall-clock stamp still lives
1483 /// at [`Self::observed_now`] and the phase projection still
1484 /// lives at [`crate::pool::EphemeralPool::observed_phase_from`].
1485 /// - **Members ride through by owned value:** the members `Vec`
1486 /// is consumed by [`Self::observed_now`] verbatim (no defensive
1487 /// `.clone()` at the composer boundary); the phase projection
1488 /// borrows the same slice through `&members[..]` inside the
1489 /// delegation so the underlying single-pass fold in
1490 /// [`crate::pool::MemberState::counts_toward_supply`]-family
1491 /// composers still gets the same borrowed view it did pre-lift.
1492 /// - **Wall-clock read once:** `Utc::now()` is called exactly ONCE
1493 /// per invocation (inherited from [`Self::observed_now`]),
1494 /// preserving the pre-lift shape where each of the two status-
1495 /// patch sites computed its own `chrono::Utc::now()` at its own
1496 /// line.
1497 ///
1498 /// # `#[must_use]`
1499 ///
1500 /// Every consumer feeds the returned [`PoolStatus`] into
1501 /// `tatara_process::patch::merge_status(&pool_api, &name,
1502 /// &<status>)` or a peer status-patch call. Dropping the return
1503 /// means the observation composed for no observable reason — the
1504 /// attribute surfaces that as a warning at every call site.
1505 ///
1506 /// Sibling of [`Self::observed_now`] on the (pure phase argument,
1507 /// pool-derived phase) axis pair: both compose atop the 3-arg
1508 /// [`Self::observed`] primitive, differing only in whether the
1509 /// caller has already computed the phase (`observed_now`) or
1510 /// hands the pool + members observations to the composer to
1511 /// derive the phase in one shot (`observed_from`). Peer of
1512 /// [`crate::pool::EphemeralPool::observed_phase_from`] on the
1513 /// (pure typed projection, compound-composer) axis.
1514 ///
1515 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1516 /// the 2-link `pool_phase_from_members + observed_now` chain
1517 /// recurred at 2 hand-authored sites past the ★★ PRIME-DIRECTIVE
1518 /// ≥ 2 duplication trigger, and is lifted to ONE substrate owner
1519 /// here). THEORY.md §II.1 invariant 5 (composition preserves
1520 /// proofs — the compound composer inherits the two component
1521 /// composers' invariants mechanically, so a regression at either
1522 /// component surfaces at the pinned tests here rather than as
1523 /// silent skew at either status-patch site).
1524 #[must_use]
1525 pub fn observed_from(pool: &EphemeralPool, members: Vec<PoolMember>) -> Self {
1526 let phase = pool.observed_phase_from(&members);
1527 Self::observed_now(phase, members)
1528 }
1529}
1530
1531impl PoolMember {
1532 /// Substrate primitive: single-pass closed-set fold over a
1533 /// `[PoolMember]` slice producing the `(ready, allocated,
1534 /// spawning, returning)` 4-tuple every `PoolStatus` seed stamps at
1535 /// its four counter slots. The `Failed` arm is a no-op (no
1536 /// `PoolStatus` counter tracks failed members — they surface via
1537 /// [`PoolPhase::Degraded`] instead), pinned by the closed-set
1538 /// match so a future variant that SHOULD count toward one of the
1539 /// four buckets triggers the compiler's exhaustiveness check here
1540 /// rather than silently falling through.
1541 ///
1542 /// Consumed by [`PoolStatus::observed`]. A caller that needs a
1543 /// single per-variant count outside the status-seed fanout should
1544 /// keep spelling `members.iter().filter(...).count()` rather than
1545 /// walking this 4-tuple — the fanout is shaped for the
1546 /// `PoolStatus` fill, not for arbitrary per-variant queries.
1547 #[must_use]
1548 pub fn state_count_fanout(members: &[Self]) -> (u32, u32, u32, u32) {
1549 let mut ready = 0u32;
1550 let mut allocated = 0u32;
1551 let mut spawning = 0u32;
1552 let mut returning = 0u32;
1553 for m in members {
1554 match m.state {
1555 MemberState::Free => ready += 1,
1556 MemberState::Allocated => allocated += 1,
1557 MemberState::Spawning => spawning += 1,
1558 MemberState::Returning => returning += 1,
1559 MemberState::Failed => {}
1560 }
1561 }
1562 (ready, allocated, spawning, returning)
1563 }
1564
1565 /// Substrate primitive: single-pass closed-set collection of the
1566 /// `process_name` axis over a `[PoolMember]` slice into an owned
1567 /// `HashSet<String>` — the O(1)-lookup shape every spawn-arm on
1568 /// the workspace builds pre-collision-check against a candidate
1569 /// [`crate::pool::PoolMember::process_name`] produced by
1570 /// [`tatara-pool-reconciler::naming::member_process_name`].
1571 ///
1572 /// Pre-lift the 2-line
1573 /// `members.iter().map(|m| m.process_name.clone()).collect()`
1574 /// chain was hand-authored at TWO sites past the ★★ PRIME-
1575 /// DIRECTIVE ≥ 2 duplication threshold in
1576 /// `tatara-pool-reconciler::controller_pool`, both restating the
1577 /// SAME `process_name` projection through the SAME
1578 /// `iter → map → collect` shape and both feeding a `.contains
1579 /// (&candidate)` probe:
1580 /// * `reconcile_inner`'s legacy allocation-driven
1581 /// `PoolDecision::Spawn` arm (`desired == 0` path) —
1582 /// collision-set for
1583 /// `member_process_name(&pool_name, &pool_uid, slot)` per spawn
1584 /// slot.
1585 /// * `apply_convergence_actions` — collision-set for the SAME
1586 /// composer inside the R11 desired-count
1587 /// `ConvergenceAction::CreateMember` loop.
1588 ///
1589 /// Post-lift both consumers share ONE substrate owner; the
1590 /// composed `HashSet<String>` still feeds the same
1591 /// `HashSet::<String>::contains(&candidate)` probe at each
1592 /// callsite unchanged. A future normalization step on the
1593 /// occupied-name axis (case-fold before insertion, a per-cluster
1594 /// prefix strip, deduplication against a sibling stale-name
1595 /// registry, exclusion of `Returning`/`Failed` members that no
1596 /// longer own their slot) lands at ONE substrate method rather
1597 /// than being restated at each callsite.
1598 ///
1599 /// Sibling to [`Self::state_count_fanout`] on the `(collection
1600 /// shape × slice-owned fold)` axis: both primitives fold a
1601 /// `[PoolMember]` slice into one caller-shaped aggregate in a
1602 /// single pass, both are `#[must_use]`, both take the slice by
1603 /// reference so no caller has to reshape its `Vec<PoolMember>` or
1604 /// `Vec<PoolMember>` slice upstream.
1605 ///
1606 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1607 /// the `HashSet<String>` collision-set shape recurred at TWO
1608 /// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
1609 /// duplication trigger, and is lifted to ONE owner here).
1610 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
1611 /// the pins bind the axis (`process_name`), the aggregate shape
1612 /// (`HashSet<String>`), the empty-slice corner, and the
1613 /// duplicate-name deduplication semantics `HashSet` provides
1614 /// implicitly, so a regression at any of those surfaces at
1615 /// `tests::process_names_set_*` rather than as silent occupied-
1616 /// slot skew at either spawn arm).
1617 #[must_use]
1618 pub fn process_names_set(members: &[Self]) -> std::collections::HashSet<String> {
1619 members.iter().map(|m| m.process_name.clone()).collect()
1620 }
1621
1622 /// Substrate composer for the unallocated `PoolMember` seed: the
1623 /// 4-slot `{ process_name, state, entered_state_at, allocation_ref:
1624 /// None }` fixture literal every non-`Allocated`-role callsite
1625 /// stamped by hand pre-lift.
1626 ///
1627 /// Pre-lift the 4-slot struct literal `PoolMember { process_name,
1628 /// state, entered_state_at, allocation_ref: None }` was hand-authored
1629 /// at FIVE workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
1630 /// duplication threshold across TWO crates:
1631 /// * `tatara-pool-reconciler::controller_pool::reconcile_inner` —
1632 /// the production per-owned-Process seed built inside the
1633 /// `for p in all_processes.items` walk; `entered_state_at` rides
1634 /// in from [`crate::prelude::Process::observed_phase_since`] with
1635 /// the `Utc::now` fallback at the callsite.
1636 /// * `tatara-pool-reconciler::pool_decide::tests::member` — test
1637 /// helper for the pool decision suite; `entered_state_at` rides
1638 /// in from [`crate::time::seconds_ago`].
1639 /// * `tatara-pool-reconciler::allocation_decide::tests::member` —
1640 /// test helper for the allocation decision suite;
1641 /// `entered_state_at` rides in from `Utc::now`.
1642 /// * `tatara-process::pool::tests::member` — test helper for the
1643 /// fanout / status suite; `entered_state_at` rides in from the
1644 /// epoch anchor `DateTime::<Utc>::from_timestamp(0, 0)`.
1645 /// * `tatara-process::pool::tests::named_member` — test helper for
1646 /// the `process_names_set` suite; same epoch anchor.
1647 ///
1648 /// Every one of those FIVE sites pinned `allocation_ref: None`
1649 /// verbatim — no `PoolMember` construction site in the workspace
1650 /// pairs `allocation_ref: Some(<ref>)` with a hand-authored 4-slot
1651 /// struct literal, so this composer's `None` slot is safe by
1652 /// construction (the compiler exhaustiveness check on the struct's
1653 /// four fields catches a future 5th slot addition here rather than
1654 /// at any of the callsites).
1655 ///
1656 /// Post-lift every consumer writes
1657 /// `PoolMember::unallocated(<name>, <state>, <anchor>)` and shares
1658 /// ONE substrate owner; a future promotion of the unallocated shape
1659 /// (a per-cluster clock-skew guard on the `entered_state_at`
1660 /// anchor, a canonical rename of the None-slot to a typed
1661 /// `Unallocated` marker, a lint-friendly closed-set restriction to
1662 /// the four `MemberState` variants that legitimately carry no
1663 /// `allocation_ref`) lands at ONE substrate site and every downstream
1664 /// consumer inherits the upgrade mechanically.
1665 ///
1666 /// `impl Into<String>` accepts both `&str` literals (every test
1667 /// helper site) and owned `String` produced by
1668 /// [`crate::prelude::Process::owned_name_or_empty`] (the production
1669 /// controller-pool site) without widening the signature.
1670 ///
1671 /// Sibling to [`AllocationRef::new`] on the substrate-composer
1672 /// axis: both take `impl Into<String>`-gated identity slots and
1673 /// return their owner-type by value; [`AllocationRef::new`] owns
1674 /// the (name, namespace) pair, this composer owns the four-slot
1675 /// unallocated-member seed.
1676 ///
1677 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1678 /// the 4-slot unallocated-`PoolMember` seed recurred at FIVE hand-
1679 /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
1680 /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
1681 /// invariant 5 (composition preserves proofs — the pins bind the
1682 /// four-slot fill AND the `allocation_ref: None` invariant AND the
1683 /// caller-clock-injectability of `entered_state_at`, so a
1684 /// regression that drifts any surface fails at
1685 /// `tests::pool_member_unallocated_*` rather than as silent
1686 /// operator-facing skew between the production controller-pool seed
1687 /// and the three test-suite helpers on the SAME `PoolMember`
1688 /// shape).
1689 #[must_use]
1690 pub fn unallocated(
1691 process_name: impl Into<String>,
1692 state: MemberState,
1693 entered_state_at: DateTime<Utc>,
1694 ) -> Self {
1695 Self {
1696 process_name: process_name.into(),
1697 state,
1698 entered_state_at,
1699 allocation_ref: None,
1700 }
1701 }
1702}
1703
1704/// Light reference to an `EphemeralAllocation`.
1705#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
1706#[serde(rename_all = "camelCase")]
1707pub struct AllocationRef {
1708 pub name: String,
1709 pub namespace: String,
1710}
1711
1712impl AllocationRef {
1713 /// Substrate constructor for [`AllocationRef`]: composes the
1714 /// `(name, namespace)` pair through ONE `impl Into<String>`-gated
1715 /// entry point — the ONE-liner collapse of the paired
1716 /// `AllocationRef { name: n.into(), namespace: ns.into() }`
1717 /// struct-literal incantation every downstream consumer restated
1718 /// by hand pre-lift.
1719 ///
1720 /// Pre-lift the `AllocationRef { name, namespace }` struct-literal
1721 /// was hand-authored at FOUR production sites past the ★★ PRIME-
1722 /// DIRECTIVE ≥ 2 duplication threshold across the workspace, all
1723 /// composing an owned `(name: String, namespace: String)` pair
1724 /// under one of two roles:
1725 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
1726 /// Bind path — the `assignedProcess` status slot's ref, pairing
1727 /// the just-bound member Process name with the allocation's
1728 /// containing namespace.
1729 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
1730 /// Release path — the same `assignedProcess` slot shape, stamped
1731 /// at the release-side status patch alongside the (unchanged)
1732 /// `boundPool` ref.
1733 /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`
1734 /// pool-matched handle — the `matched_pool` slot's ref, pairing
1735 /// [`EphemeralPool::owned_name_or_empty`] with the pool's
1736 /// containing namespace.
1737 /// * `tatara-github-watcher::allocation_factory::allocation_from_pr`
1738 /// — the `pool_ref` slot on the `AllocationSpec` emitted from a
1739 /// PullRequestEvent, pairing the operator-configured pool name
1740 /// with the watcher's target namespace.
1741 ///
1742 /// All FOUR sites walked the SAME two-field struct-literal shape
1743 /// — an owned name half, an owned namespace half — differing only
1744 /// in provenance. Post-lift each callsite reads
1745 /// `AllocationRef::new(name, ns)` and the produced value feeds the
1746 /// same downstream slot (`assignedProcess` / `bound_pool` /
1747 /// `matched_pool` / `spec.pool_ref`) unchanged. The `impl Into<String>`
1748 /// signature accepts every provenance the pre-lift sites carried —
1749 /// owned `String` (the reconciler's owned-form projections), `&str`
1750 /// (the factory's `n.to_string()` / `namespace.to_string()`
1751 /// borrow-to-owned promotions), `Cow<str>`, and every other
1752 /// `Into<String>` implementor — so no callsite has to change its
1753 /// upstream provenance to route through the primitive.
1754 ///
1755 /// Return-form axis: owned [`AllocationRef`] — the wire-format
1756 /// shape [`crate::pool::AllocationRef`]'s serde `rename_all =
1757 /// "camelCase"` produces on both spec (`poolRef`) and status
1758 /// (`boundPool` / `assignedProcess`) slots. The primitive owns
1759 /// the axis-order `(name, namespace)` — the same order the four
1760 /// consumers spelled — so a slot swap surfaces at the
1761 /// `allocation_ref_new_positional_axis_order` pin below rather
1762 /// than as silent `<namespace>/<name>` inversion downstream.
1763 ///
1764 /// Peer to the sibling substrate primitives already opened on the
1765 /// pool-side (name, namespace) axis pair:
1766 /// [`EphemeralPool::name_or_empty`] (borrow-form name),
1767 /// [`EphemeralPool::owned_name_or_empty`] (owned-form name); this
1768 /// constructor is the composer that folds the owned-form projections
1769 /// into the wire-format ref shape.
1770 ///
1771 /// A future refactor of [`AllocationRef`]'s field set (a
1772 /// `resource_kind: String` field for cross-CRD refs, an
1773 /// `api_version: String` field for FQN references, a
1774 /// canonicalization pass over the namespace half, a non-empty-name
1775 /// gate) lands at ONE substrate constructor site here and every
1776 /// downstream consumer inherits the upgrade mechanically — no per-
1777 /// callsite hand-edit at the FOUR reconciler + factory sites.
1778 ///
1779 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1780 /// the `AllocationRef { name, namespace }` struct-literal shape
1781 /// recurred at four hand-authored sites past the ★★ PRIME-
1782 /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
1783 /// here). THEORY.md §II.1 invariant 5 (composition preserves
1784 /// proofs — the pins bind the positional axis-order + the
1785 /// `Into<String>` provenance closure + byte-identical parity with
1786 /// the pre-lift struct-literal + `PartialEq` coherence with the
1787 /// hand-authored form, so a regression that reshaped any surface
1788 /// at `tests::allocation_ref_new_*` rather than as silent
1789 /// operator-facing skew between the assignedProcess / bound_pool
1790 /// / matched_pool / spec.pool_ref slots on the SAME allocation).
1791 #[must_use]
1792 pub fn new(name: impl Into<String>, namespace: impl Into<String>) -> Self {
1793 Self {
1794 name: name.into(),
1795 namespace: namespace.into(),
1796 }
1797 }
1798}
1799
1800/// Per-slot state in the pool's free list.
1801///
1802/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
1803/// (the on-failure policy that the pool reconciler dispatches against
1804/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
1805/// release-time disposition that transitions an [`Self::Allocated`]
1806/// member into [`Self::Returning`] before it either re-enters
1807/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
1808#[derive(
1809 Clone,
1810 Copy,
1811 Debug,
1812 PartialEq,
1813 Eq,
1814 Hash,
1815 Serialize,
1816 Deserialize,
1817 JsonSchema,
1818 tatara_closed_set::DeriveClosedSet,
1819)]
1820#[serde(rename_all = "PascalCase")]
1821#[closed_set(via = "as_str", generate_unknown, display)]
1822pub enum MemberState {
1823 /// Pool reconciler is creating/converging the backing Process.
1824 Spawning,
1825 /// Process is `Attested`; ready for allocation.
1826 Free,
1827 /// Held by an `EphemeralAllocation`.
1828 Allocated,
1829 /// Return policy is being applied (Reset → reset Job; Replace →
1830 /// Process is being torn down and recreated).
1831 Returning,
1832 /// Permanent failure — the member needs operator attention.
1833 Failed,
1834}
1835
1836impl MemberState {
1837 /// The closed set of member states — single source of truth that
1838 /// drives the `as_str` / Display / `FromStr` triad AND the
1839 /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
1840 /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
1841 /// arm per predicate — exhaustively checked by the compiler (the
1842 /// `[Self; 5]` array literal forces the arity) and by the
1843 /// per-variant truth-table contract test (a new variant must
1844 /// declare its own `(is_failed, counts_toward_supply)` projection
1845 /// or the consumer dispatch in
1846 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1847 /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1848 /// will silently bucket it into the wrong lifecycle column).
1849 pub const ALL: [Self; 5] = [
1850 Self::Spawning,
1851 Self::Free,
1852 Self::Allocated,
1853 Self::Returning,
1854 Self::Failed,
1855 ];
1856
1857 /// Canonical PascalCase wire-format projection — matches the serde
1858 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
1859 /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
1860 /// `status.members[].state`. Pinned by
1861 /// `member_state_as_str_matches_serde` so a variant rename can't
1862 /// drift between the typed surface, the CRD enum, the YAML wire
1863 /// format AND any future operator-facing diagnostic that composes
1864 /// `state={state}` via Display rather than a hard-coded literal
1865 /// that would silently rot.
1866 pub const fn as_str(self) -> &'static str {
1867 match self {
1868 Self::Spawning => "Spawning",
1869 Self::Free => "Free",
1870 Self::Allocated => "Allocated",
1871 Self::Returning => "Returning",
1872 Self::Failed => "Failed",
1873 }
1874 }
1875
1876 /// Is this member in a permanent-failure state — needs operator
1877 /// attention? Closed-set match (not `matches!`) so a future variant
1878 /// triggers the compiler's exhaustiveness check at this site rather
1879 /// than silently defaulting to `false`. Consumed by
1880 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
1881 /// gate the highest-priority `ReplaceMembers` decision branch — a
1882 /// future variant that should also trigger replacement (e.g.
1883 /// `MemberState::Quarantined`) flips this predicate at one site
1884 /// and inherits the priority-1 dispatch without touching the
1885 /// consumer match arm.
1886 pub const fn is_failed(self) -> bool {
1887 match self {
1888 Self::Failed => true,
1889 Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
1890 }
1891 }
1892
1893 /// Does this member contribute to the pool's *available supply*
1894 /// (current ready slots + slots coming online)? Closed-set match so
1895 /// a future variant triggers the compiler's exhaustiveness check.
1896 /// Consumed by
1897 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1898 /// — the `(free + spawning)` supply calc collapses into one
1899 /// predicate-driven filter, so a future "warming-up" state
1900 /// (`MemberState::Warming` between Spawning and Free) plugs into
1901 /// the supply count at one site rather than three. Disjoint with
1902 /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
1903 /// (a Failed member can never count toward supply; the pool
1904 /// reconciler would otherwise double-count failures as available
1905 /// capacity).
1906 pub const fn counts_toward_supply(self) -> bool {
1907 match self {
1908 Self::Free | Self::Spawning => true,
1909 Self::Allocated | Self::Returning | Self::Failed => false,
1910 }
1911 }
1912}
1913
1914// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
1915// MemberState` + `impl fmt::Display for MemberState` are generated by
1916// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1917// above. `label` delegates to the inherent `MemberState::as_str` via
1918// `#[closed_set(via = "as_str")]` so the
1919// `pool_phase_from_members` supply calc can keep keying on
1920// `counts_toward_supply` against the typed variant while a generic
1921// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
1922// (`label`) without knowing this enum lives in `tatara-process::pool`;
1923// Display delegates to the same inherent projection via
1924// `#[closed_set(display)]` so the diagnostic emitter's
1925// `state={state}` composition stays pinned on the closed-set algebra.
1926
1927// `pub struct UnknownMemberState(pub String)` is generated by
1928// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1929// on the enum declaration above. The auto-derived label `"member state"`
1930// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
1931// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1932// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
1933// [`crate::lifetime::UnknownTeardownPolicy`],
1934// [`crate::boundary::UnknownConditionKind`], and
1935// [`crate::phase::UnknownPhase`].
1936
1937/// Pool lifecycle phase (observed across the whole pool population).
1938///
1939/// Sibling closed-set on the same `EphemeralPool` axis as
1940/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
1941/// over via [`MemberState::counts_toward_supply`]),
1942/// [`ReplacementPolicy::ALL`] (on-failure policy) and
1943/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
1944/// `MemberState`, this closes the pool reconciler's
1945/// `(slot-state, pool-phase)` two-tier observation algebra on the
1946/// same closed-set discipline as the rest of `tatara-process`.
1947#[derive(
1948 Clone,
1949 Copy,
1950 Debug,
1951 PartialEq,
1952 Eq,
1953 Hash,
1954 Serialize,
1955 Deserialize,
1956 JsonSchema,
1957 tatara_closed_set::DeriveClosedSet,
1958)]
1959#[serde(rename_all = "PascalCase")]
1960#[closed_set(via = "as_str", generate_unknown, display)]
1961pub enum PoolPhase {
1962 /// Just admitted; no members yet.
1963 Initializing,
1964 /// `ready_count == desired_size`.
1965 Steady,
1966 /// `ready_count + spawning_count < desired_size` and reconciler
1967 /// is creating new members.
1968 ScalingUp,
1969 /// `ready_count > desired_size` and reconciler is reaping excess.
1970 ScalingDown,
1971 /// `min_size` constraint violated.
1972 Degraded,
1973 /// Pool is being deleted; reconciler is reaping all members.
1974 Draining,
1975}
1976
1977impl Default for PoolPhase {
1978 fn default() -> Self {
1979 Self::Initializing
1980 }
1981}
1982
1983impl PoolPhase {
1984 /// The closed set of pool phases — single source of truth that
1985 /// drives the `as_str` / Display / `FromStr` triad AND the
1986 /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
1987 /// variant lands at one `ALL` entry + one `as_str` arm + one arm
1988 /// per predicate — exhaustively checked by the compiler (the
1989 /// `[Self; 6]` array literal forces the arity) AND by the
1990 /// per-variant truth-table contract test (a new variant must
1991 /// declare its own `(is_steady, is_terminal)` projection or any
1992 /// future status-aggregator surface — `feira pool list
1993 /// --healthy`, the operator-facing condition aggregator, the
1994 /// desired-loop heartbeat short-circuit — will silently bucket
1995 /// it into the wrong lifecycle column).
1996 pub const ALL: [Self; 6] = [
1997 Self::Initializing,
1998 Self::Steady,
1999 Self::ScalingUp,
2000 Self::ScalingDown,
2001 Self::Degraded,
2002 Self::Draining,
2003 ];
2004
2005 /// Canonical PascalCase wire-format projection — matches the
2006 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
2007 /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
2008 /// stamps on `status.phase`. Pinned by
2009 /// `pool_phase_as_str_matches_serde` so a variant rename can't
2010 /// drift between the typed surface, the CRD enum, the YAML wire
2011 /// format AND any future operator-facing diagnostic that
2012 /// composes `phase={phase}` via Display rather than a hard-coded
2013 /// literal that would silently rot. Display + FromStr triad
2014 /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
2015 /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
2016 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
2017 pub const fn as_str(self) -> &'static str {
2018 match self {
2019 Self::Initializing => "Initializing",
2020 Self::Steady => "Steady",
2021 Self::ScalingUp => "ScalingUp",
2022 Self::ScalingDown => "ScalingDown",
2023 Self::Degraded => "Degraded",
2024 Self::Draining => "Draining",
2025 }
2026 }
2027
2028 /// Is the pool fully converged — supply matches desired, no
2029 /// reconciler-driven population change pending? Closed-set match
2030 /// (not `matches!`) so a future variant triggers the compiler's
2031 /// exhaustiveness check at this site rather than silently
2032 /// defaulting to `false`. Paired with `is_terminal` they form
2033 /// the two-axis projection that future status aggregators
2034 /// (operator-facing fleet health, `feira pool list --healthy`,
2035 /// the SSE filter "show non-steady pools") dispatch against —
2036 /// `is_steady && !is_terminal` ⇒ converged (goal state);
2037 /// `!is_steady && is_terminal` ⇒ being deleted (no future
2038 /// spawn); `!is_steady && !is_terminal` ⇒ transient
2039 /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
2040 /// is in motion toward desired). The impossible bucket
2041 /// `(true, true)` — a draining pool that's somehow also steady
2042 /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
2043 pub const fn is_steady(self) -> bool {
2044 match self {
2045 Self::Steady => true,
2046 Self::Initializing
2047 | Self::ScalingUp
2048 | Self::ScalingDown
2049 | Self::Degraded
2050 | Self::Draining => false,
2051 }
2052 }
2053
2054 /// Is the pool in its absorbing exit state — deletion-stamped,
2055 /// reconciler is reaping every member, no spawn will ever
2056 /// happen again? Closed-set match so a future variant triggers
2057 /// the compiler's exhaustiveness check. See `is_steady` for the
2058 /// predicate-pair contract + bucket definitions.
2059 pub const fn is_terminal(self) -> bool {
2060 match self {
2061 Self::Draining => true,
2062 Self::Initializing
2063 | Self::Steady
2064 | Self::ScalingUp
2065 | Self::ScalingDown
2066 | Self::Degraded => false,
2067 }
2068 }
2069}
2070
2071// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
2072// + `impl fmt::Display for PoolPhase` are generated by
2073// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
2074// `label` delegates to the inherent `PoolPhase::as_str` via
2075// `#[closed_set(via = "as_str")]` so the operator-facing
2076// `phase={phase}` Display composition keeps reading the same canonical
2077// PascalCase projection while a generic `T: ClosedSet` consumer (a
2078// status-aggregator filter, the `feira pool list --healthy` predicate, a
2079// future SSE event router) can walk every variant without knowing the
2080// closed set lives in `tatara-process::pool`; Display delegates to the
2081// same inherent projection via `#[closed_set(display)]` so the
2082// `phase={phase}` composition stays pinned on the closed-set algebra
2083// rather than a hand-rolled `fmt::Display` block.
2084
2085// `pub struct UnknownPoolPhase(pub String)` is generated by
2086// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
2087// on the enum declaration above. The auto-derived label `"pool phase"`
2088// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
2089// verbatim. Symmetric to [`UnknownMemberState`],
2090// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
2091// [`crate::lifetime::UnknownTeardownPolicy`],
2092// [`crate::boundary::UnknownConditionKind`], and
2093// [`crate::phase::UnknownPhase`].
2094
2095/// Standard K8s Condition shape (kept local so tatara-process doesn't
2096/// depend on k8s_openapi types in its public schema).
2097#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
2098#[serde(rename_all = "camelCase")]
2099pub struct PoolCondition {
2100 pub type_: String,
2101 pub status: String,
2102 pub reason: String,
2103 pub message: String,
2104 pub last_transition_time: DateTime<Utc>,
2105}
2106
2107/// What the pool does when an allocation releases a member.
2108///
2109/// Sibling closed-set on the `EphemeralPool` axis:
2110/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
2111/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
2112/// (the *release*-time counterpart for non-pooled ephemeral envs),
2113/// [`crate::boundary::ConditionKind::ALL`],
2114/// [`crate::lifetime::LifetimeKind::ALL`],
2115/// [`crate::intent::IntentKind::ALL`],
2116/// [`crate::phase::ProcessPhase::ALL`],
2117/// [`crate::signal::ProcessSignal::ALL`].
2118#[derive(
2119 Clone,
2120 Copy,
2121 Debug,
2122 Hash,
2123 PartialEq,
2124 Eq,
2125 Serialize,
2126 Deserialize,
2127 JsonSchema,
2128 Default,
2129 tatara_closed_set::DeriveClosedSet,
2130)]
2131#[serde(rename_all = "PascalCase")]
2132#[closed_set(via = "as_str", generate_unknown, display)]
2133pub enum ReturnPolicy {
2134 /// Tear down the Process + create a fresh one. Safe but slow
2135 /// (1-2 min spin-up before the slot is Free again).
2136 #[default]
2137 Replace,
2138 /// Keep the Process running; run a typed `:reset` Job that wipes
2139 /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
2140 /// the reset Job being correct for the workload. API-authoritative
2141 /// systems are natural fits because the control API owns all state.
2142 Reset,
2143 /// Keep the Process indefinitely after release (debugging aid;
2144 /// operator must `feira pool reap NAME` to clean up). Useful for
2145 /// post-mortem of a flaky test.
2146 Keep,
2147}
2148
2149impl ReturnPolicy {
2150 /// The closed set of return policies — single source of truth that
2151 /// drives the `as_str` / Display / `FromStr` triad and the
2152 /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
2153 /// fourth variant lands at one `ALL` entry + one `as_str` arm +
2154 /// one arm per predicate — exhaustively checked by the compiler
2155 /// (the `[Self; 3]` array literal forces the arity) and by the
2156 /// predicate-pair injectivity test (a new variant must land in
2157 /// its own (keeps_process, runs_reset_job) bucket or the author
2158 /// has to extend the consumer dispatch in
2159 /// `tatara-pool-reconciler::return_policy::plan_return`).
2160 pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
2161
2162 /// Canonical PascalCase wire-format projection — matches the
2163 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
2164 /// `enum:` enumeration the pool reconciler stamps on the
2165 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
2166 /// `return_policy_as_str_matches_serde` so a variant rename can't
2167 /// drift between the typed surface, the CRD enum, the YAML wire
2168 /// format AND any future operator-facing diagnostic that composes
2169 /// `policy={policy}` via Display rather than a hard-coded literal.
2170 pub const fn as_str(self) -> &'static str {
2171 match self {
2172 Self::Replace => "Replace",
2173 Self::Reset => "Reset",
2174 Self::Keep => "Keep",
2175 }
2176 }
2177
2178 /// Does the pool keep the backing Process alive across release?
2179 /// Closed-set match (not `matches!`) so a future variant triggers
2180 /// the compiler's exhaustiveness check at this site rather than
2181 /// silently defaulting to `false`. Paired with `runs_reset_job`
2182 /// they form the two-axis projection that the consumer in
2183 /// `tatara-pool-reconciler::return_policy::plan_return` matches
2184 /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
2185 /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
2186 /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
2187 /// pair is `(false, false) | (true, true) | (true, false)` —
2188 /// pinned injective by
2189 /// `return_policy_predicate_pair_is_injective`.
2190 pub const fn keeps_process(self) -> bool {
2191 match self {
2192 Self::Replace => false,
2193 Self::Reset | Self::Keep => true,
2194 }
2195 }
2196
2197 /// Does the policy run a typed `:reset` Job to wipe state in
2198 /// place? See `keeps_process` for the closed-match rationale +
2199 /// the predicate-pair contract.
2200 pub const fn runs_reset_job(self) -> bool {
2201 match self {
2202 Self::Reset => true,
2203 Self::Replace | Self::Keep => false,
2204 }
2205 }
2206}
2207
2208// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
2209// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
2210// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
2211// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
2212// `#[closed_set(via = "as_str")]` so the
2213// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
2214// reading the canonical PascalCase projection that matches the CRD
2215// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
2216// plugs in without knowing the enum lives in `tatara-process::pool`;
2217// Display delegates to the same inherent projection via
2218// `#[closed_set(display)]` so the `policy={policy}` diagnostic
2219// composition stays pinned on the closed-set algebra.
2220
2221// `pub struct UnknownReturnPolicy(pub String)` is generated by
2222// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
2223// on the enum declaration above. The auto-derived label `"return policy"`
2224// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
2225// verbatim. Symmetric to [`UnknownReplacementPolicy`],
2226// [`UnknownMemberState`], [`UnknownPoolPhase`],
2227// [`crate::lifetime::UnknownTeardownPolicy`],
2228// [`crate::boundary::UnknownConditionKind`], and
2229// [`crate::phase::UnknownPhase`].
2230
2231/// Routing selector — matches an `EphemeralAllocation`'s requestor
2232/// against pool-eligibility predicates.
2233#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
2234#[serde(rename_all = "camelCase")]
2235pub struct PoolSelector {
2236 /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
2237 /// Empty = match every repo.
2238 #[serde(default)]
2239 pub repos: Vec<String>,
2240
2241 /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
2242 /// Empty = match every branch.
2243 #[serde(default)]
2244 pub branches: Vec<String>,
2245
2246 /// PR labels (all-must-match, AND semantics). Empty = no label
2247 /// requirement.
2248 #[serde(default)]
2249 pub pr_labels: Vec<String>,
2250
2251 /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
2252 /// "manual", "ci-run"). Empty = any kind.
2253 #[serde(default)]
2254 pub kinds: Vec<String>,
2255}
2256
2257impl PoolSelector {
2258 /// Does this selector match the given allocation routing key?
2259 /// Pure: no side effects.
2260 pub fn matches(&self, key: &MatchKey<'_>) -> bool {
2261 glob_any(&self.repos, key.repo)
2262 && glob_any(&self.branches, key.branch)
2263 && labels_subset(&self.pr_labels, key.pr_labels)
2264 && kind_any(&self.kinds, key.kind)
2265 }
2266
2267 /// Specificity score — higher = more specific. Used by the
2268 /// reconciler to break ties between selectors that all match.
2269 pub fn specificity(&self) -> u32 {
2270 let mut score = 0;
2271 if !self.repos.is_empty() {
2272 score += 8;
2273 }
2274 if !self.branches.is_empty() {
2275 score += 4;
2276 }
2277 score += (self.pr_labels.len() as u32) * 2;
2278 if !self.kinds.is_empty() {
2279 score += 1;
2280 }
2281 score
2282 }
2283}
2284
2285/// Allocation routing key — what the reconciler matches against pool selectors.
2286#[derive(Clone, Copy, Debug)]
2287pub struct MatchKey<'a> {
2288 pub repo: &'a str,
2289 pub branch: &'a str,
2290 pub pr_labels: &'a [String],
2291 pub kind: &'a str,
2292}
2293
2294fn glob_any(patterns: &[String], value: &str) -> bool {
2295 if patterns.is_empty() {
2296 return true;
2297 }
2298 patterns.iter().any(|p| glob_match(p, value))
2299}
2300
2301fn kind_any(kinds: &[String], value: &str) -> bool {
2302 if kinds.is_empty() {
2303 return true;
2304 }
2305 kinds.iter().any(|k| k == value)
2306}
2307
2308fn labels_subset(required: &[String], present: &[String]) -> bool {
2309 required.iter().all(|r| present.iter().any(|p| p == r))
2310}
2311
2312/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
2313/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
2314/// matches anything.
2315fn glob_match(pattern: &str, value: &str) -> bool {
2316 if pattern.is_empty() {
2317 return true;
2318 }
2319 if let Some(prefix) = pattern.strip_suffix('*') {
2320 value.starts_with(prefix)
2321 } else {
2322 pattern == value
2323 }
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328 use super::*;
2329 // The closed-set tests below call `T::from_str(bad)` via the
2330 // derive-generated `FromStr` impls — bring the trait into scope at
2331 // the test module so the lib body doesn't carry an otherwise-unused
2332 // `use std::str::FromStr;` at the file head.
2333 use std::str::FromStr;
2334
2335 #[test]
2336 fn glob_trailing_star_matches_prefix() {
2337 assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
2338 assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
2339 assert!(glob_match("release-*", "release-2026-05"));
2340 assert!(!glob_match("release-*", "main"));
2341 assert!(glob_match("main", "main"));
2342 assert!(!glob_match("main", "develop"));
2343 }
2344
2345 #[test]
2346 fn empty_selector_matches_anything() {
2347 let s = PoolSelector::default();
2348 assert!(s.matches(&MatchKey {
2349 repo: "any/repo",
2350 branch: "any-branch",
2351 pr_labels: &[],
2352 kind: "any",
2353 }));
2354 }
2355
2356 #[test]
2357 fn repo_glob_filters_match_key() {
2358 let s = PoolSelector {
2359 repos: vec!["pleme-io/demo-*".into()],
2360 ..Default::default()
2361 };
2362 assert!(s.matches(&MatchKey {
2363 repo: "pleme-io/demo-app",
2364 branch: "x",
2365 pr_labels: &[],
2366 kind: "y",
2367 }));
2368 assert!(!s.matches(&MatchKey {
2369 repo: "pleme-io/other-repo",
2370 branch: "x",
2371 pr_labels: &[],
2372 kind: "y",
2373 }));
2374 }
2375
2376 #[test]
2377 fn pr_labels_require_all() {
2378 let s = PoolSelector {
2379 pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
2380 ..Default::default()
2381 };
2382 // Both labels present → match.
2383 assert!(s.matches(&MatchKey {
2384 repo: "x",
2385 branch: "y",
2386 pr_labels: &[
2387 "needs-ephemeral".into(),
2388 "integration".into(),
2389 "extra".into()
2390 ],
2391 kind: "z",
2392 }));
2393 // One label missing → no match.
2394 assert!(!s.matches(&MatchKey {
2395 repo: "x",
2396 branch: "y",
2397 pr_labels: &["needs-ephemeral".into()],
2398 kind: "z",
2399 }));
2400 }
2401
2402 #[test]
2403 fn specificity_ranks_more_constrained_higher() {
2404 let general = PoolSelector::default();
2405 let specific = PoolSelector {
2406 repos: vec!["pleme-io/*".into()],
2407 branches: vec!["main".into()],
2408 pr_labels: vec!["needs-ephemeral".into()],
2409 kinds: vec!["github-pr".into()],
2410 };
2411 assert!(specific.specificity() > general.specificity());
2412 }
2413
2414 #[test]
2415 fn return_policy_defaults_to_replace() {
2416 assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
2417 }
2418
2419 #[test]
2420 fn pool_phase_defaults_to_initializing() {
2421 assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
2422 }
2423
2424 // ── closed-set algebra contracts for ReplacementPolicy
2425 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
2426
2427 /// Structural well-formedness of [`ReplacementPolicy`] as a
2428 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
2429 /// testkit lift that pins all three structural invariants (`ALL`
2430 /// is non-empty, every variant round-trips through
2431 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
2432 /// outside the closed set) at ONE call site. Replaces the hand-
2433 /// derived `replacement_policy_all_is_unique_and_complete` +
2434 /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
2435 /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
2436 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
2437 /// exercises the same code path the pool reconciler hits when
2438 /// parsing a CRD `enum:`-validated value back to the typed policy.
2439 #[test]
2440 fn replacement_policy_is_well_formed_closed_set() {
2441 tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
2442 }
2443
2444 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2445 /// output verbatim for every variant. A future variant rename (or
2446 /// an `as_str` arm typo) lands here at one site, instead of
2447 /// drifting between the typed surface, the CRD enum, and the
2448 /// YAML wire format.
2449 #[test]
2450 fn replacement_policy_as_str_matches_serde() {
2451 crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
2452 }
2453
2454 /// The Display impl IS `as_str` — pinning this lets future callers
2455 /// reach for either projection without drift. The operator-facing
2456 /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
2457 /// composes through Display rather than through a hard-coded
2458 /// variant string.
2459 #[test]
2460 fn replacement_policy_display_matches_as_str() {
2461 crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
2462 }
2463
2464 /// `FromStr` rejects strings that aren't in the canonical
2465 /// projection — lowercased / typo / cross-axis-leaked — and the
2466 /// error echoes the input verbatim so the operator-facing
2467 /// diagnostic carries the offending value, not a normalized form.
2468 /// The empty-input arm is pinned by
2469 /// [`replacement_policy_is_well_formed_closed_set`] via the
2470 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
2471 /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
2472 /// newtype, which the trait's `make_unknown` can't see.
2473 #[test]
2474 fn unknown_replacement_policy_errors() {
2475 for bad in [
2476 "replaceimmediate",
2477 "PAUSEPOOL",
2478 "Replace-Immediate",
2479 "hold_failed",
2480 "Pause",
2481 "Reset",
2482 ] {
2483 let err = ReplacementPolicy::from_str(bad).unwrap_err();
2484 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2485 }
2486 }
2487
2488 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2489 /// documented per-variant on-failure behavior.
2490 #[test]
2491 fn replacement_policy_predicate_truth_tables() {
2492 assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
2493 assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
2494
2495 assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
2496 assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
2497
2498 assert!(!ReplacementPolicy::PausePool.replaces_failed());
2499 assert!(ReplacementPolicy::PausePool.pauses_on_failure());
2500 }
2501
2502 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2503 /// predicates simultaneously — the two on-failure actions
2504 /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
2505 /// A future `ReplacementPolicy::PauseAndReap` that returned true
2506 /// from both would FAIL here, forcing the author to either pick
2507 /// one bucket or extend the consumer dispatch site in
2508 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
2509 /// deliberately rather than silently double-firing both branches.
2510 #[test]
2511 fn replacement_policy_predicates_are_disjoint() {
2512 for policy in ReplacementPolicy::ALL {
2513 assert!(
2514 !(policy.replaces_failed() && policy.pauses_on_failure()),
2515 "{policy:?} returns true from both replaces_failed and pauses_on_failure",
2516 );
2517 }
2518 }
2519
2520 /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
2521 /// pauses_on_failure)` is injective across `ALL`. Each variant
2522 /// projects to its own `(bool, bool)` bucket: `(true, false)` =
2523 /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
2524 /// this with the disjointness contract above forces a future
2525 /// variant to land in a fresh `(replaces_failed,
2526 /// pauses_on_failure)` bucket — or the author extends the consumer
2527 /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
2528 /// to recognize the new projection bucket.
2529 #[test]
2530 fn replacement_policy_predicate_pair_is_injective() {
2531 let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
2532 .into_iter()
2533 .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
2534 .collect();
2535 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
2536 assert_eq!(
2537 projections.len(),
2538 unique.len(),
2539 "predicate pair projection is not injective: {projections:?}",
2540 );
2541 }
2542
2543 /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
2544 /// returns the variant tagged `#[default]` in the enum, AND that
2545 /// variant reaps (the production-safe behavior). A future #[default]
2546 /// rename without flipping the predicates fails here.
2547 #[test]
2548 fn replacement_policy_default_replaces_failed() {
2549 let d = ReplacementPolicy::default();
2550 assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
2551 assert!(d.replaces_failed());
2552 assert!(!d.pauses_on_failure());
2553 }
2554
2555 #[test]
2556 fn kinds_filter_to_known_set() {
2557 let s = PoolSelector {
2558 kinds: vec!["github-pr".into(), "manual".into()],
2559 ..Default::default()
2560 };
2561 assert!(s.matches(&MatchKey {
2562 repo: "x",
2563 branch: "y",
2564 pr_labels: &[],
2565 kind: "github-pr",
2566 }));
2567 assert!(!s.matches(&MatchKey {
2568 repo: "x",
2569 branch: "y",
2570 pr_labels: &[],
2571 kind: "scheduled",
2572 }));
2573 }
2574
2575 // ── closed-set algebra contracts for ReturnPolicy
2576 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
2577
2578 /// Structural well-formedness of [`ReturnPolicy`] as a
2579 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2580 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
2581 /// above.
2582 #[test]
2583 fn return_policy_is_well_formed_closed_set() {
2584 tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
2585 }
2586
2587 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2588 /// output verbatim for every variant. A future variant rename (or
2589 /// an `as_str` arm typo) lands here at one site, instead of
2590 /// drifting between the typed surface, the CRD enum, and the
2591 /// YAML wire format.
2592 #[test]
2593 fn return_policy_as_str_matches_serde() {
2594 crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
2595 }
2596
2597 /// The Display impl IS `as_str` — pinning this lets future callers
2598 /// reach for either projection without drift, mirroring the
2599 /// `ReplacementPolicy` discipline.
2600 #[test]
2601 fn return_policy_display_matches_as_str() {
2602 crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
2603 }
2604
2605 /// `FromStr` rejects strings that aren't in the canonical
2606 /// projection — lowercased / typo / cross-axis-leaked — and the
2607 /// error echoes the input verbatim so the operator-facing
2608 /// diagnostic carries the offending value, not a normalized form.
2609 /// The empty-input arm is pinned by
2610 /// [`return_policy_is_well_formed_closed_set`] via the
2611 /// `tatara_lisp::ClosedSet` testkit.
2612 #[test]
2613 fn unknown_return_policy_errors() {
2614 for bad in [
2615 "replace",
2616 "RESET",
2617 "Re-place",
2618 "keep_for_inspection",
2619 "DeleteAndRespawn",
2620 "ReplaceImmediate",
2621 ] {
2622 let err = ReturnPolicy::from_str(bad).unwrap_err();
2623 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2624 }
2625 }
2626
2627 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2628 /// documented per-variant on-release behavior.
2629 #[test]
2630 fn return_policy_predicate_truth_tables() {
2631 assert!(!ReturnPolicy::Replace.keeps_process());
2632 assert!(!ReturnPolicy::Replace.runs_reset_job());
2633
2634 assert!(ReturnPolicy::Reset.keeps_process());
2635 assert!(ReturnPolicy::Reset.runs_reset_job());
2636
2637 assert!(ReturnPolicy::Keep.keeps_process());
2638 assert!(!ReturnPolicy::Keep.runs_reset_job());
2639 }
2640
2641 /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
2642 /// You cannot run a typed `:reset` Job against a Process you've
2643 /// just deleted; the impossible bucket `(false, true)` must stay
2644 /// empty. A future variant returning true from `runs_reset_job`
2645 /// while returning false from `keeps_process` fails here, which
2646 /// forces the author to either flip `keeps_process` to true or
2647 /// extend the consumer dispatch site in
2648 /// `tatara-pool-reconciler::return_policy::plan_return`
2649 /// deliberately rather than letting an impossible state slip in.
2650 #[test]
2651 fn return_policy_reset_implies_keeps_process() {
2652 for policy in ReturnPolicy::ALL {
2653 if policy.runs_reset_job() {
2654 assert!(
2655 policy.keeps_process(),
2656 "{policy:?} runs a reset job but does not keep the process",
2657 );
2658 }
2659 }
2660 }
2661
2662 /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
2663 /// is injective across `ALL`. Each variant projects to its own
2664 /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
2665 /// `(true, true)` = reset-in-place; `(true, false)` = keep for
2666 /// inspection. Pairing this with the implication contract above
2667 /// forces a future variant to land in a fresh
2668 /// `(keeps_process, runs_reset_job)` bucket — or the author
2669 /// extends the consumer dispatch in
2670 /// `tatara-pool-reconciler::return_policy::plan_return` to
2671 /// recognize the new projection bucket.
2672 #[test]
2673 fn return_policy_predicate_pair_is_injective() {
2674 let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
2675 .into_iter()
2676 .map(|p| (p.keeps_process(), p.runs_reset_job()))
2677 .collect();
2678 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
2679 assert_eq!(
2680 projections.len(),
2681 unique.len(),
2682 "predicate pair projection is not injective: {projections:?}",
2683 );
2684 }
2685
2686 /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
2687 /// the variant tagged `#[default]` in the enum, AND that variant
2688 /// is the safe "tear down + respawn" behavior — neither keeps the
2689 /// process nor runs a reset Job. A future `#[default]` rename
2690 /// without flipping the predicates fails here.
2691 #[test]
2692 fn return_policy_default_is_replace_and_neither_predicate_fires() {
2693 let d = ReturnPolicy::default();
2694 assert_eq!(d, ReturnPolicy::Replace);
2695 assert!(!d.keeps_process());
2696 assert!(!d.runs_reset_job());
2697 }
2698
2699 // ── closed-set algebra contracts for MemberState
2700 // (ALL × as_str × FromStr × predicate pair) ────────────────────
2701
2702 /// Structural well-formedness of [`MemberState`] as a
2703 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2704 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
2705 /// and [`return_policy_is_well_formed_closed_set`] above.
2706 #[test]
2707 fn member_state_is_well_formed_closed_set() {
2708 tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
2709 }
2710
2711 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2712 /// output verbatim for every variant. A future variant rename (or
2713 /// an `as_str` arm typo) lands here at one site, instead of
2714 /// drifting between the typed surface, the CRD enum, and the YAML
2715 /// wire format the pool reconciler stamps on
2716 /// `status.members[].state`.
2717 #[test]
2718 fn member_state_as_str_matches_serde() {
2719 crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
2720 }
2721
2722 /// The Display impl IS `as_str` — pinning this lets future callers
2723 /// reach for either projection without drift. Any operator-facing
2724 /// "state={state}" diagnostic that composes through Display
2725 /// inherits the canonical wire-format string automatically.
2726 #[test]
2727 fn member_state_display_matches_as_str() {
2728 crate::tagged_union::assert_display_matches_label::<MemberState>();
2729 }
2730
2731 /// `FromStr` rejects strings that aren't in the canonical
2732 /// projection — lowercased / typo / cross-axis-leaked — and
2733 /// the error echoes the input verbatim so the operator-facing
2734 /// diagnostic carries the offending value, not a normalized form.
2735 /// The empty-input arm is pinned by
2736 /// [`member_state_is_well_formed_closed_set`] via the
2737 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
2738 /// pin the closed-set REJECTION contract that the trait can't see:
2739 /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
2740 /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
2741 /// `ProcessPhase`) but MUST reject here, because the codomains
2742 /// are disjoint.
2743 #[test]
2744 fn unknown_member_state_errors() {
2745 for bad in [
2746 "free",
2747 "SPAWNING",
2748 "Free-State",
2749 "allocated_now",
2750 "ReplaceImmediate", // ReplacementPolicy-axis leak
2751 "Reset", // ReturnPolicy-axis leak
2752 "Attested", // ProcessPhase-axis leak
2753 ] {
2754 let err = MemberState::from_str(bad).unwrap_err();
2755 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2756 }
2757 }
2758
2759 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2760 /// documented per-variant lifecycle role. The pool reconciler's
2761 /// `pool_phase_from_members` supply calc collapses
2762 /// `count_state(Free) + count_state(Spawning)` into one
2763 /// `counts_toward_supply` filter; this table pins the per-variant
2764 /// projection that consumer depends on.
2765 #[test]
2766 fn member_state_predicate_truth_tables() {
2767 assert!(!MemberState::Spawning.is_failed());
2768 assert!(MemberState::Spawning.counts_toward_supply());
2769
2770 assert!(!MemberState::Free.is_failed());
2771 assert!(MemberState::Free.counts_toward_supply());
2772
2773 assert!(!MemberState::Allocated.is_failed());
2774 assert!(!MemberState::Allocated.counts_toward_supply());
2775
2776 assert!(!MemberState::Returning.is_failed());
2777 assert!(!MemberState::Returning.counts_toward_supply());
2778
2779 assert!(MemberState::Failed.is_failed());
2780 assert!(!MemberState::Failed.counts_toward_supply());
2781 }
2782
2783 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2784 /// `is_failed` and `counts_toward_supply` simultaneously — a
2785 /// failed member can never be counted as available capacity. A
2786 /// future variant that returned true from both would FAIL here,
2787 /// forcing the author to either drop it from supply, or extend
2788 /// the consumer's bucketing in
2789 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
2790 /// deliberately rather than silently inflating the pool's supply
2791 /// count with failed slots.
2792 #[test]
2793 fn member_state_failed_implies_no_supply() {
2794 for state in MemberState::ALL {
2795 assert!(
2796 !(state.is_failed() && state.counts_toward_supply()),
2797 "{state:?} returns true from both is_failed and counts_toward_supply — \
2798 a failed member can never be counted as available pool capacity",
2799 );
2800 }
2801 }
2802
2803 /// COVERAGE CONTRACT: every variant lands somewhere — either
2804 /// in supply, or as a failed slot, or as an in-use bucket
2805 /// (`Allocated | Returning`). A future variant that returns
2806 /// `false` from `counts_toward_supply` AND `false` from
2807 /// `is_failed` is fine *iff* it represents an in-use slot; this
2808 /// test pins the existing variants in their declared buckets so
2809 /// the consumer-side dispatch in
2810 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
2811 /// stays grounded.
2812 #[test]
2813 fn member_state_buckets_cover_every_variant() {
2814 let mut supply = 0u32;
2815 let mut failed = 0u32;
2816 let mut in_use = 0u32;
2817 for state in MemberState::ALL {
2818 match (state.is_failed(), state.counts_toward_supply()) {
2819 (true, false) => failed += 1,
2820 (false, true) => supply += 1,
2821 (false, false) => in_use += 1,
2822 (true, true) => panic!("disjointness already pins this empty for {state:?}"),
2823 }
2824 }
2825 assert_eq!(supply, 2, "supply bucket: Free + Spawning");
2826 assert_eq!(failed, 1, "failed bucket: Failed");
2827 assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
2828 assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
2829 }
2830
2831 // ── closed-set algebra contracts for PoolPhase
2832 // (ALL × as_str × FromStr × predicate pair) ────────────────────
2833
2834 /// Structural well-formedness of [`PoolPhase`] as a
2835 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
2836 /// symmetric to [`member_state_is_well_formed_closed_set`] above.
2837 #[test]
2838 fn pool_phase_is_well_formed_closed_set() {
2839 tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
2840 }
2841
2842 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
2843 /// output verbatim for every variant. A future variant rename (or
2844 /// an `as_str` arm typo) lands here at one site, instead of
2845 /// drifting between the typed surface, the CRD enum, and the YAML
2846 /// wire format the pool reconciler stamps on `status.phase`.
2847 #[test]
2848 fn pool_phase_as_str_matches_serde() {
2849 crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
2850 }
2851
2852 /// The Display impl IS `as_str` — pinning this lets future callers
2853 /// reach for either projection without drift. Any operator-facing
2854 /// "phase={phase}" diagnostic that composes through Display
2855 /// inherits the canonical wire-format string automatically.
2856 #[test]
2857 fn pool_phase_display_matches_as_str() {
2858 crate::tagged_union::assert_display_matches_label::<PoolPhase>();
2859 }
2860
2861 /// `FromStr` rejects strings that aren't in the canonical
2862 /// projection — lowercased / typo / cross-axis-leaked — and
2863 /// the error echoes the input verbatim so the operator-facing
2864 /// diagnostic carries the offending value, not a normalized form.
2865 /// The empty-input arm is pinned by
2866 /// [`pool_phase_is_well_formed_closed_set`] via the
2867 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
2868 /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
2869 /// closed-set REJECTION contract that the trait can't see — those
2870 /// are valid sibling-axis labels but MUST reject here.
2871 #[test]
2872 fn unknown_pool_phase_errors() {
2873 for bad in [
2874 "steady",
2875 "SCALINGUP",
2876 "Scaling-Up",
2877 "scaling_down",
2878 "Free", // MemberState-axis leak
2879 "Replace", // ReturnPolicy-axis leak
2880 "Attested", // ProcessPhase-axis leak
2881 "HoldFailed", // ReplacementPolicy-axis leak
2882 ] {
2883 let err = PoolPhase::from_str(bad).unwrap_err();
2884 assert_eq!(err.0, bad, "error payload should echo input verbatim");
2885 }
2886 }
2887
2888 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
2889 /// documented per-variant lifecycle role. Pinning this table at
2890 /// one site means any future status-aggregator surface
2891 /// (`feira pool list --healthy`, the SSE filter, the desired-loop
2892 /// heartbeat short-circuit) reads the same projection that the
2893 /// reconciler writes.
2894 #[test]
2895 fn pool_phase_predicate_truth_tables() {
2896 assert!(!PoolPhase::Initializing.is_steady());
2897 assert!(!PoolPhase::Initializing.is_terminal());
2898
2899 assert!(PoolPhase::Steady.is_steady());
2900 assert!(!PoolPhase::Steady.is_terminal());
2901
2902 assert!(!PoolPhase::ScalingUp.is_steady());
2903 assert!(!PoolPhase::ScalingUp.is_terminal());
2904
2905 assert!(!PoolPhase::ScalingDown.is_steady());
2906 assert!(!PoolPhase::ScalingDown.is_terminal());
2907
2908 assert!(!PoolPhase::Degraded.is_steady());
2909 assert!(!PoolPhase::Degraded.is_terminal());
2910
2911 assert!(!PoolPhase::Draining.is_steady());
2912 assert!(PoolPhase::Draining.is_terminal());
2913 }
2914
2915 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
2916 /// `is_steady` and `is_terminal` simultaneously — a draining pool
2917 /// is by definition transitioning OUT, not the goal converged
2918 /// state. A future variant that returned true from both would
2919 /// FAIL here, forcing the author to either pick one bucket or
2920 /// extend the consumer dispatch sites (status aggregators,
2921 /// heartbeat short-circuit) deliberately rather than silently
2922 /// double-firing both branches.
2923 #[test]
2924 fn pool_phase_steady_excludes_terminal() {
2925 for phase in PoolPhase::ALL {
2926 assert!(
2927 !(phase.is_steady() && phase.is_terminal()),
2928 "{phase:?} returns true from both is_steady and is_terminal — \
2929 a draining pool is by definition not the converged goal state",
2930 );
2931 }
2932 }
2933
2934 /// COVERAGE CONTRACT: every variant lands somewhere — either the
2935 /// converged goal (`Steady`), the absorbing exit (`Draining`),
2936 /// or the transient bucket (`Initializing | ScalingUp |
2937 /// ScalingDown | Degraded` — pool is in motion toward desired).
2938 /// A future variant that returns `false` from BOTH predicates is
2939 /// fine *iff* it represents an in-motion state; this test pins
2940 /// the existing variants in their declared buckets so the
2941 /// projection consumers stay grounded.
2942 #[test]
2943 fn pool_phase_buckets_cover_every_variant() {
2944 let mut converged = 0u32;
2945 let mut terminal = 0u32;
2946 let mut transient = 0u32;
2947 for phase in PoolPhase::ALL {
2948 match (phase.is_steady(), phase.is_terminal()) {
2949 (true, false) => converged += 1,
2950 (false, true) => terminal += 1,
2951 (false, false) => transient += 1,
2952 (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
2953 }
2954 }
2955 assert_eq!(converged, 1, "converged bucket: Steady");
2956 assert_eq!(terminal, 1, "terminal bucket: Draining");
2957 assert_eq!(
2958 transient, 4,
2959 "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
2960 );
2961 assert_eq!(
2962 converged + terminal + transient,
2963 PoolPhase::ALL.len() as u32
2964 );
2965 }
2966
2967 /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
2968 /// variant a freshly-admitted pool should land in — `Initializing`
2969 /// — AND that variant is neither steady (no members yet) nor
2970 /// terminal (not deletion-stamped). A future `Default` rename
2971 /// without flipping the predicates fails here.
2972 #[test]
2973 fn pool_phase_default_is_initializing_in_transient_bucket() {
2974 let d = PoolPhase::default();
2975 assert_eq!(d, PoolPhase::Initializing);
2976 assert!(!d.is_steady());
2977 assert!(!d.is_terminal());
2978 }
2979
2980 // ─────────────────────────────────────────────────────────────────
2981 // `EphemeralPool::name_or_empty` — borrow-form metadata-projection
2982 // primitive on the `metadata.name` axis. Pins the missing-slot
2983 // corner, the populated-slot corner, the pre-lift chain-shape
2984 // parity, and the pure-projection discipline that the two
2985 // `tatara-pool-reconciler` consumers routed onto the primitive
2986 // depend on. See the primitive's doc-comment for the full
2987 // migration rationale.
2988 // ─────────────────────────────────────────────────────────────────
2989
2990 fn empty_template() -> EphemeralSpec {
2991 EphemeralSpec {
2992 aplicacao: crate::intent::AplicacaoIntent::chart_only("oci://x", "1"),
2993 ttl: "1h".into(),
2994 teardown: crate::lifetime::TeardownPolicy::Always,
2995 max_concurrent: 0,
2996 postconditions: vec![],
2997 preconditions: vec![],
2998 verify_timeout: None,
2999 classification: None,
3000 parent: None,
3001 exports: vec![],
3002 routing: None,
3003 }
3004 }
3005
3006 fn pool_spec() -> PoolSpec {
3007 // Every non-template slot rides the ONE substrate composer
3008 // [`PoolSpec::with_template`] at its wire-published default;
3009 // pre-lift this fixture spelled the full 11-slot struct-literal
3010 // verbatim as one of eight cross-crate hand-authored copies past
3011 // the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold. See the
3012 // primitive's doc-comment for the full migration rationale.
3013 PoolSpec {
3014 desired_size: 1,
3015 ..PoolSpec::with_template(empty_template())
3016 }
3017 }
3018
3019 fn pool_named(name: &str) -> EphemeralPool {
3020 EphemeralPool::new(name, pool_spec())
3021 }
3022
3023 fn pool_unnamed() -> EphemeralPool {
3024 let mut p = EphemeralPool::new("scratch", pool_spec());
3025 p.metadata.name = None;
3026 p
3027 }
3028
3029 #[test]
3030 fn name_or_empty_returns_empty_string_when_metadata_name_is_none() {
3031 let p = pool_unnamed();
3032 assert!(p.metadata.name.is_none(), "fixture invariant");
3033 assert_eq!(p.name_or_empty(), "");
3034 }
3035
3036 #[test]
3037 fn name_or_empty_returns_populated_slot_verbatim() {
3038 let p = pool_named("attest-pool");
3039 assert_eq!(p.name_or_empty(), "attest-pool");
3040 }
3041
3042 #[test]
3043 fn name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
3044 // Corner between `None` (missing slot) and `Some(String::new())`
3045 // (populated slot containing the empty string): the primitive
3046 // MUST fold both to the same `""` byte-shape so a downstream
3047 // `HashMap<String,_>::get(name)` / `str::cmp` sees ONE
3048 // "unnamed pool" bucket regardless of which shape the K8s API
3049 // server materialized. This is byte-identical to what the
3050 // pre-lift `.as_deref().unwrap_or("")` chain produced.
3051 let mut p = pool_named("scratch");
3052 p.metadata.name = Some(String::new());
3053 assert_eq!(p.name_or_empty(), "");
3054 }
3055
3056 #[test]
3057 fn name_or_empty_is_a_pure_projection() {
3058 // Consecutive calls return byte-identical slices — no cached
3059 // state, no mutation on the `EphemeralPool` between calls.
3060 // Guards against a future refactor that plants a cache field
3061 // and drifts one caller from another silently.
3062 let p = pool_named("router-pool");
3063 assert_eq!(p.name_or_empty(), p.name_or_empty());
3064 assert_eq!(p.name_or_empty(), "router-pool");
3065 assert_eq!(p.name_or_empty(), "router-pool");
3066 }
3067
3068 #[test]
3069 fn name_or_empty_matches_pre_lift_chain_verbatim() {
3070 // Byte-identical parity with the two hand-authored
3071 // `.metadata.name.as_deref().unwrap_or("")` chains the
3072 // primitive replaces in `tatara-pool-reconciler::router` and
3073 // `tatara-pool-reconciler::controller_allocation`. Runs across
3074 // the FULL corner set of the metadata.name slot: absent,
3075 // present-with-value, present-with-empty-string.
3076 let cases: [(Option<String>, &str); 3] = [
3077 (None, ""),
3078 (Some("attest-pool".into()), "attest-pool"),
3079 (Some(String::new()), ""),
3080 ];
3081 for (slot, expected) in cases {
3082 let mut p = pool_named("scratch");
3083 p.metadata.name = slot.clone();
3084 let pre_lift = p.metadata.name.as_deref().unwrap_or("");
3085 assert_eq!(pre_lift, expected, "pre-lift chain sanity");
3086 assert_eq!(p.name_or_empty(), pre_lift);
3087 assert_eq!(p.name_or_empty(), expected);
3088 }
3089 }
3090
3091 #[test]
3092 fn name_or_empty_borrows_from_metadata_name_slot() {
3093 // The returned `&str` is tied to the `EphemeralPool`'s
3094 // lifetime — the caller can compare / hash / index without
3095 // allocating. This is the load-bearing property that lets
3096 // the `HashMap<String, _>::get(pool.name_or_empty())` closure
3097 // in `controller_allocation::reconcile_inner` skip cloning.
3098 let p = pool_named("attest-pool");
3099 let s: &str = p.name_or_empty();
3100 assert_eq!(s.as_ptr(), p.metadata.name.as_deref().unwrap().as_ptr());
3101 }
3102
3103 // ─── EphemeralPool::owned_name_or_empty substrate pins ────────────
3104 //
3105 // The owned-form peer of the borrow-form `name_or_empty` primitive
3106 // above. Sibling to the sister-CRD primitive
3107 // `crate::crd::Process::owned_name_or_empty` (owned + empty sentinel
3108 // on `Process::metadata.name`) — the four primitives now partition
3109 // the (borrow × owned) × (name × uid) corner of the metadata-slot
3110 // family on identical missing-slot semantics across BOTH tatara-
3111 // process CRDs (`Process::uid_or_empty` + `Process::owned_name_or_empty`
3112 // + `EphemeralPool::name_or_empty` + this method). Fail-before-pass-
3113 // after granularity: `owned_name_or_empty` did not exist on the pool
3114 // CRD pre-lift; the compiler cannot resolve the name until the impl
3115 // block above is in place, so a rollback of the primitive breaks
3116 // this whole module.
3117 #[test]
3118 fn owned_name_or_empty_returns_empty_string_when_metadata_name_is_none() {
3119 let p = pool_unnamed();
3120 assert!(p.metadata.name.is_none(), "fixture invariant");
3121 assert_eq!(p.owned_name_or_empty(), String::new());
3122 }
3123
3124 #[test]
3125 fn owned_name_or_empty_returns_owned_string_when_slot_is_populated() {
3126 let p = pool_named("attest-pool");
3127 assert_eq!(p.owned_name_or_empty(), "attest-pool");
3128 }
3129
3130 #[test]
3131 fn owned_name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
3132 // Corner between `None` (missing slot) and `Some(String::new())`
3133 // (populated slot containing the empty string): the primitive
3134 // MUST fold both to the same `""` byte-shape so a downstream
3135 // `HashMap<String,_>::get(name)` sees ONE "unnamed pool" bucket
3136 // regardless of which shape the K8s API server materialized.
3137 // Byte-identical to what the pre-lift `.clone().unwrap_or_default()`
3138 // chain produced.
3139 let mut p = pool_named("scratch");
3140 p.metadata.name = Some(String::new());
3141 assert_eq!(p.owned_name_or_empty(), String::new());
3142 assert!(p.owned_name_or_empty().is_empty());
3143 }
3144
3145 #[test]
3146 fn owned_name_or_empty_is_a_pure_projection() {
3147 // Consecutive calls return byte-identical Strings — no cached
3148 // state, no mutation on the `EphemeralPool` between calls.
3149 // Guards against a future refactor that plants a cache field
3150 // and drifts one caller from another silently.
3151 let p = pool_named("router-pool");
3152 assert_eq!(p.owned_name_or_empty(), p.owned_name_or_empty());
3153 assert_eq!(p.owned_name_or_empty(), "router-pool");
3154 assert_eq!(p.owned_name_or_empty(), "router-pool");
3155 }
3156
3157 #[test]
3158 fn owned_name_or_empty_matches_pre_lift_chain_verbatim() {
3159 // Byte-identical parity with the two hand-authored
3160 // `.metadata.name.clone().unwrap_or_default()` chains the
3161 // primitive replaces in `tatara-pool-reconciler::
3162 // controller_allocation::reconcile_inner` (HashMap key seed)
3163 // and `tatara-pool-reconciler::allocation_decide::
3164 // AllocationConvergenceCtx::observe` (AllocationRef.name slot
3165 // seed). Runs across the FULL corner set of the metadata.name
3166 // slot: absent, present-with-value, present-with-empty-string.
3167 // A regression that inserted a normalization step at the
3168 // primitive the pre-lift chain does NOT apply — or vice versa —
3169 // surfaces here rather than as silent drift between the two
3170 // owned-form callsites and the ONE substrate owner they now
3171 // route through.
3172 let cases: [(Option<String>, &str); 3] = [
3173 (None, ""),
3174 (Some("attest-pool".into()), "attest-pool"),
3175 (Some(String::new()), ""),
3176 ];
3177 for (slot, expected) in cases {
3178 let mut p = pool_named("scratch");
3179 p.metadata.name = slot.clone();
3180 let pre_lift = p.metadata.name.clone().unwrap_or_default();
3181 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
3182 assert_eq!(p.owned_name_or_empty(), pre_lift);
3183 assert_eq!(p.owned_name_or_empty().as_str(), expected);
3184 }
3185 }
3186
3187 #[test]
3188 fn owned_name_or_empty_matches_borrow_form_peer_on_populated_slot() {
3189 // Cross-primitive coherence pin at the sibling corner: when the
3190 // slot is present, the borrow-form (`name_or_empty`) and owned-
3191 // form (`owned_name_or_empty`) primitives return the SAME byte
3192 // sequence and differ only in ownership. A regression that
3193 // skewed one form's fallback would surface here rather than as
3194 // silent drift between the router tie-break comparator and the
3195 // AllocationRef seed on the SAME pool.
3196 let p = pool_named("attest-pool");
3197 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
3198 }
3199
3200 #[test]
3201 fn owned_name_or_empty_matches_borrow_form_peer_on_missing_slot() {
3202 // Sibling corner of the coherence pin above: when the slot is
3203 // absent (or explicitly empty), BOTH primitives fold to the
3204 // same empty-string byte-shape. The load-bearing property is
3205 // that a caller who switches between the two return-forms
3206 // based on downstream ownership requirements never sees a
3207 // different missing-slot spelling as a side effect.
3208 let p = pool_unnamed();
3209 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
3210 assert_eq!(p.name_or_empty(), "");
3211 assert_eq!(p.owned_name_or_empty(), String::new());
3212 }
3213
3214 // ─── EphemeralPool::is_being_deleted substrate pins ───────────────
3215 //
3216 // Pins the copy-form metadata-projection primitive on the deletion-
3217 // tombstone axis of the pool CRD. Peer to the borrow-form + owned-
3218 // form metadata-fallback family (`name_or_empty`,
3219 // `owned_name_or_empty`); this one opens the presence-probe corner
3220 // for the tombstone slot. Sibling to the sister-CRD primitive
3221 // `crate::crd::Process::is_being_deleted` — the two primitives
3222 // now partition the tombstone-presence probe across BOTH tatara-
3223 // process CRDs on identical missing-slot semantics. Fail-before-
3224 // pass-after granularity: `is_being_deleted` did not exist on the
3225 // pool CRD pre-lift; the compiler cannot resolve the name until
3226 // the impl block above is in place, so a rollback of the primitive
3227 // breaks this whole module.
3228
3229 fn tombstoned_pool() -> EphemeralPool {
3230 let mut p = pool_named("attest-pool");
3231 p.metadata.namespace = Some("ephemeral-pools".into());
3232 // Routes through the ONE substrate composer
3233 // `tatara_process::time::tombstone_now` — see the peer
3234 // `tombstoned_process` doc-comment in `crd.rs` for the full
3235 // migration rationale.
3236 p.metadata.deletion_timestamp = crate::time::tombstone_now();
3237 p
3238 }
3239
3240 #[test]
3241 fn is_being_deleted_returns_false_when_deletion_timestamp_is_absent() {
3242 // Missing-tombstone corner pin: the primitive collapses the
3243 // no-tombstone case to `false` so the `→ Drain` short-circuit
3244 // at `decide_pool_reconcile` is NOT taken and the observed-
3245 // phase composer at `pool_phase_from_members` proceeds to its
3246 // normal (free / spawning / allocated) arithmetic branches
3247 // instead of short-circuiting to `PoolPhase::Draining`.
3248 // Matches the pre-lift `.is_some()` chain's `false` byte-
3249 // identically at every consumer's downstream gate.
3250 let mut p = pool_named("attest-pool");
3251 p.metadata.deletion_timestamp = None;
3252 assert!(!p.is_being_deleted());
3253 }
3254
3255 #[test]
3256 fn is_being_deleted_returns_true_when_deletion_timestamp_is_present() {
3257 // Present-tombstone corner pin: the primitive returns `true`
3258 // on any populated `metadata.deletionTimestamp` slot regardless
3259 // of the timestamp payload — the two consumers only read the
3260 // tombstone's PRESENCE, never its RFC-3339 timestamp value.
3261 // A regression that gated the `true` return on the timestamp
3262 // being non-epoch, or parsed the timestamp before returning,
3263 // would surface here rather than as silent skew at the
3264 // `→ Drain` decision or the `→ Draining` phase report on the
3265 // SAME `EphemeralPool`.
3266 let p = tombstoned_pool();
3267 assert!(p.is_being_deleted());
3268 }
3269
3270 #[test]
3271 fn is_being_deleted_is_a_pure_projection() {
3272 // Purity pin: two consecutive calls return byte-identical
3273 // `bool` values (no lazy materialization, no interior
3274 // mutation of `self`). Peer to the sibling
3275 // `name_or_empty_is_a_pure_projection` +
3276 // `owned_name_or_empty_is_a_pure_projection` pins in this
3277 // module and to `is_being_deleted_is_a_pure_projection` on
3278 // the sister-CRD `Process`; all four bind the pure-projection
3279 // discipline on the ONE substrate accessor per metadata slot.
3280 let p = tombstoned_pool();
3281 let a = p.is_being_deleted();
3282 let b = p.is_being_deleted();
3283 assert_eq!(a, b);
3284 assert!(a);
3285 }
3286
3287 #[test]
3288 fn is_being_deleted_matches_pre_lift_pool_reconciler_chain_shape() {
3289 // Parity pin: sweeps the two corners every pre-lift consumer
3290 // plausibly encountered (missing tombstone, present tombstone)
3291 // and compares the substrate call against a hand-authored pre-
3292 // lift chain byte-identically. A regression that reshaped
3293 // either corner would surface here rather than as silent
3294 // operator-facing skew between the pool-reconciler's `→ Drain`
3295 // decision and the observed-phase composer's `→ Draining`
3296 // report on the SAME `EphemeralPool` within one reconcile
3297 // pass.
3298 fn pre_lift(p: &EphemeralPool) -> bool {
3299 p.metadata.deletion_timestamp.is_some()
3300 }
3301 // Missing slot.
3302 let mut p = pool_named("attest-pool");
3303 p.metadata.deletion_timestamp = None;
3304 assert_eq!(p.is_being_deleted(), pre_lift(&p));
3305 // Populated slot.
3306 let p = tombstoned_pool();
3307 assert_eq!(p.is_being_deleted(), pre_lift(&p));
3308 }
3309
3310 #[test]
3311 fn is_being_deleted_composes_with_pool_phase_draining_at_reconcile_preempt() {
3312 // Call-site-shape pin: the `pool_phase_from_members`
3313 // deletion-preempt returns `PoolPhase::Draining` as soon as
3314 // `pool.is_being_deleted()` holds, regardless of the (free +
3315 // spawning) supply arithmetic that would otherwise pick
3316 // `Ready` / `Scaling` / `Degraded`. The `→ Drain` decision at
3317 // `decide_pool_reconcile` composes with the same probe on the
3318 // same tombstone-presence slot. A regression that broadened
3319 // the tombstone probe implicitly (returning `false` on a
3320 // present but zero-timestamp) or narrowed it (requiring an
3321 // additional `.finalizers.is_empty()` conjunct that the two
3322 // consumers never spelled) would surface here rather than as
3323 // silent operator-facing skew between the pool reconciler's
3324 // decision and the observed-phase composer on the SAME
3325 // `EphemeralPool` within one reconcile pass.
3326 let alive = pool_named("attest-pool");
3327 assert!(!alive.is_being_deleted());
3328 let dying = tombstoned_pool();
3329 assert!(dying.is_being_deleted());
3330 }
3331
3332 // ─── EphemeralPool::owned_namespace_or_empty substrate pins ───────
3333 //
3334 // The owned-form peer of the `owned_name_or_empty` primitive on the
3335 // sibling `metadata.namespace` axis — the paired half of the
3336 // `AllocationRef { name, namespace }` struct literal both
3337 // `AllocationConvergenceCtx::observe` and the composition pin
3338 // consume through the SAME `AllocationRef::new(name, namespace)`
3339 // constructor. Fail-before-pass-after granularity:
3340 // `owned_namespace_or_empty` did not exist on the pool CRD pre-
3341 // lift; the compiler cannot resolve the name until the impl block
3342 // above is in place, so a rollback of the primitive breaks this
3343 // whole module.
3344 #[test]
3345 fn owned_namespace_or_empty_returns_empty_string_when_metadata_namespace_is_none() {
3346 // Missing-slot corner pin: the primitive collapses the no-
3347 // namespace case to the load-bearing empty-string sentinel so
3348 // the downstream `AllocationRef.namespace` slot carries `""`
3349 // rather than a defaulted `"default"` string. See the doc-
3350 // comment's DELIBERATE-EMPTY-SENTINEL rationale for why the
3351 // fallback matches `.clone().unwrap_or_default()` byte-for-
3352 // byte rather than substituting `Process::DEFAULT_NAMESPACE`
3353 // at the primitive.
3354 let mut p = pool_named("attest-pool");
3355 p.metadata.namespace = None;
3356 assert!(p.metadata.namespace.is_none(), "fixture invariant");
3357 assert_eq!(p.owned_namespace_or_empty(), String::new());
3358 }
3359
3360 #[test]
3361 fn owned_namespace_or_empty_returns_owned_string_when_slot_is_populated() {
3362 let mut p = pool_named("attest-pool");
3363 p.metadata.namespace = Some("ephemeral-pools".into());
3364 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
3365 }
3366
3367 #[test]
3368 fn owned_namespace_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
3369 // Corner between `None` (missing slot) and `Some(String::new())`
3370 // (populated slot containing the empty string): the primitive
3371 // MUST fold both to the same `""` byte-shape so a downstream
3372 // `AllocationRef.namespace ==` comparator at
3373 // `resolve_pool` sees ONE "unset namespace" bucket regardless
3374 // of which shape the K8s API server materialized. Byte-
3375 // identical to what the pre-lift `.clone().unwrap_or_default()`
3376 // chain produced.
3377 let mut p = pool_named("attest-pool");
3378 p.metadata.namespace = Some(String::new());
3379 assert_eq!(p.owned_namespace_or_empty(), String::new());
3380 assert!(p.owned_namespace_or_empty().is_empty());
3381 }
3382
3383 #[test]
3384 fn owned_namespace_or_empty_is_a_pure_projection() {
3385 // Consecutive calls return byte-identical Strings — no cached
3386 // state, no mutation on the `EphemeralPool` between calls.
3387 // Peer to the sibling `owned_name_or_empty_is_a_pure_projection`
3388 // pin in this module and to `is_being_deleted_is_a_pure_projection`
3389 // on the same CRD; all three bind the pure-projection
3390 // discipline on the ONE substrate accessor per metadata slot.
3391 let mut p = pool_named("attest-pool");
3392 p.metadata.namespace = Some("ephemeral-pools".into());
3393 assert_eq!(p.owned_namespace_or_empty(), p.owned_namespace_or_empty());
3394 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
3395 assert_eq!(p.owned_namespace_or_empty(), "ephemeral-pools");
3396 }
3397
3398 #[test]
3399 fn owned_namespace_or_empty_matches_pre_lift_chain_verbatim() {
3400 // Byte-identical parity with the two hand-authored
3401 // `.metadata.namespace.clone().unwrap_or_default()` chains
3402 // the primitive replaces in `tatara-pool-reconciler::
3403 // allocation_decide::AllocationConvergenceCtx::observe`
3404 // (matched-pool `AllocationRef.namespace` seed) and in the
3405 // sibling composition pin
3406 // `allocation_ref_new_composes_with_owned_name_or_empty_pool_projection`.
3407 // Runs across the FULL corner set of the metadata.namespace
3408 // slot: absent, present-with-value, present-with-empty-string.
3409 // A regression that inserted a normalization step at the
3410 // primitive the pre-lift chain does NOT apply — or vice versa —
3411 // surfaces here rather than as silent drift between the two
3412 // owned-form callsites and the ONE substrate owner they now
3413 // route through.
3414 let cases: [(Option<String>, &str); 3] = [
3415 (None, ""),
3416 (Some("ephemeral-pools".into()), "ephemeral-pools"),
3417 (Some(String::new()), ""),
3418 ];
3419 for (slot, expected) in cases {
3420 let mut p = pool_named("attest-pool");
3421 p.metadata.namespace = slot.clone();
3422 let pre_lift = p.metadata.namespace.clone().unwrap_or_default();
3423 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
3424 assert_eq!(p.owned_namespace_or_empty(), pre_lift);
3425 assert_eq!(p.owned_namespace_or_empty().as_str(), expected);
3426 }
3427 }
3428
3429 #[test]
3430 fn owned_namespace_or_empty_composes_with_owned_name_or_empty_on_paired_slot_axis() {
3431 // Paired-axis coherence pin: the two owned-form primitives on
3432 // the pool CRD's `metadata.name` + `metadata.namespace` slots
3433 // share the SAME empty-string sentinel on the missing corner,
3434 // so a caller that composes both halves into an
3435 // `AllocationRef` (as `AllocationConvergenceCtx::observe`
3436 // does) never sees a mixed-fallback pair (one `""`, the
3437 // other `"default"`) as a side effect of one slot being
3438 // absent. A regression that skewed either primitive's
3439 // fallback would surface here rather than as silent operator-
3440 // facing skew between the paired halves of the SAME
3441 // `AllocationRef` seed.
3442 let mut p = pool_named("attest-pool");
3443 p.metadata.namespace = None;
3444 p.metadata.name = None;
3445 assert_eq!(p.owned_name_or_empty(), p.owned_namespace_or_empty());
3446 assert_eq!(p.owned_name_or_empty(), String::new());
3447 assert_eq!(p.owned_namespace_or_empty(), String::new());
3448 }
3449
3450 #[test]
3451 fn owned_namespace_or_empty_does_not_default_to_process_default_namespace() {
3452 // Deliberate-empty-sentinel pin: the primitive's fallback is
3453 // `""`, NOT `crate::crd::Process::DEFAULT_NAMESPACE`. The
3454 // sole downstream consumer (`AllocationConvergenceCtx::observe`)
3455 // feeds the produced value into `AllocationRef.namespace`,
3456 // which is then matched byte-identically against
3457 // `spec.pool_ref.namespace` at `resolve_pool`. A silent
3458 // substitution of `"default"` at this primitive would alias
3459 // every namespace-absent pool to the `"default"` bucket at
3460 // the matcher, hiding the missing-slot corner from an
3461 // operator who explicitly authored an allocation against a
3462 // namespace-unset pool. Pinned so a future "helpful"
3463 // canonicalization step lands as a compiler-visible failure
3464 // here rather than as silent operator-facing skew at the
3465 // matched-pool seed.
3466 let mut p = pool_named("attest-pool");
3467 p.metadata.namespace = None;
3468 assert_ne!(
3469 p.owned_namespace_or_empty(),
3470 crate::crd::Process::DEFAULT_NAMESPACE
3471 );
3472 assert_eq!(p.owned_namespace_or_empty(), "");
3473 }
3474
3475 // ─── EphemeralPool::owned_uid_or_name_or_empty substrate pins ─────
3476 //
3477 // Pins the compound owned-form projection on the paired
3478 // `(metadata.uid, metadata.name)` axis of the pool CRD — the
3479 // ONE-liner collapse of the paired `.metadata.uid.clone()
3480 // .unwrap_or_else(|| name.<into>())` chain every pool-slot-name
3481 // consumer restated by hand pre-lift at TWO production sites in
3482 // `tatara-pool-reconciler::controller_pool` (spawn arm +
3483 // apply_convergence_actions arm), both feeding the SAME
3484 // `member_process_name(&pool_name, &pool_uid_or_name_fallback,
3485 // slot)` composer. Fail-before-pass-after granularity:
3486 // `owned_uid_or_name_or_empty` did not exist on the pool CRD pre-
3487 // lift; the compiler cannot resolve the name until the impl block
3488 // above is in place, so a rollback of the primitive breaks this
3489 // whole module.
3490 #[test]
3491 fn owned_uid_or_name_or_empty_returns_uid_when_uid_is_present() {
3492 // Preferred-slot pin: uid populated → uid wins, regardless of
3493 // whether the name-fallback slot is populated. Byte-identical
3494 // to what each pre-lift `.metadata.uid.clone().unwrap_or_else
3495 // (|| name.<into>())` chain returned in the reachable-state
3496 // corner where the K8s API server has stamped a uid (the
3497 // common case at both callsites, which are already gated by
3498 // `owned_coordinates_required()?`).
3499 let mut p = pool_named("attest-pool");
3500 p.metadata.uid = Some("uid-42".into());
3501 assert_eq!(p.owned_uid_or_name_or_empty(), "uid-42");
3502 }
3503
3504 #[test]
3505 fn owned_uid_or_name_or_empty_falls_back_to_name_when_uid_is_missing() {
3506 // Fallback-slot pin: uid absent → name wins. Byte-identical
3507 // to what each pre-lift chain returned in the corner where
3508 // the K8s API server has NOT yet stamped a uid (pre-admission
3509 // / unit-test in-memory pool). The pre-lift chain reached
3510 // the fallback via a locally-bound `name` string derived from
3511 // the same `.metadata.name` slot the primitive reaches via
3512 // `owned_name_or_empty()`.
3513 let mut p = pool_named("attest-pool");
3514 p.metadata.uid = None;
3515 assert_eq!(p.owned_uid_or_name_or_empty(), "attest-pool");
3516 }
3517
3518 #[test]
3519 fn owned_uid_or_name_or_empty_sinks_to_empty_when_both_slots_are_missing() {
3520 // Missing-both corner pin: uid absent AND name absent → the
3521 // load-bearing empty-string sentinel. Coherent with the
3522 // sibling primitives `owned_name_or_empty` +
3523 // `owned_namespace_or_empty` on the SAME empty-sentinel axis.
3524 // A regression that dropped either fallback surfaces here
3525 // rather than as a runtime panic on `.unwrap()` at a spawn
3526 // callsite that assumed both slots were populated.
3527 let mut p = pool_named("attest-pool");
3528 p.metadata.uid = None;
3529 p.metadata.name = None;
3530 assert_eq!(p.owned_uid_or_name_or_empty(), String::new());
3531 assert!(p.owned_uid_or_name_or_empty().is_empty());
3532 }
3533
3534 #[test]
3535 fn owned_uid_or_name_or_empty_prefers_uid_when_both_slots_are_present() {
3536 // Precedence pin: both slots populated → uid wins. The pre-
3537 // lift `.unwrap_or_else(|| name.<into>())` chain's short-
3538 // circuit on the `Some(u)` arm skipped the fallback entirely;
3539 // the primitive matches that byte-for-byte via `.clone()
3540 // .unwrap_or_else(|| self.owned_name_or_empty())`, so the
3541 // name-fallback slot is not read when uid is populated.
3542 let mut p = pool_named("attest-pool");
3543 p.metadata.uid = Some("uid-preferred".into());
3544 p.metadata.name = Some("attest-pool".into());
3545 assert_eq!(p.owned_uid_or_name_or_empty(), "uid-preferred");
3546 assert_ne!(p.owned_uid_or_name_or_empty(), "attest-pool");
3547 }
3548
3549 #[test]
3550 fn owned_uid_or_name_or_empty_returns_uid_even_when_uid_is_explicitly_empty_string() {
3551 // Corner between `None` (missing slot) and `Some(String::new())`
3552 // (populated slot containing the empty string): the primitive
3553 // MUST return the populated-empty-string uid rather than
3554 // falling back to the name half — byte-identical to what the
3555 // pre-lift `.metadata.uid.clone().unwrap_or_else(|| name...)`
3556 // chain produced, whose `unwrap_or_else` short-circuits on
3557 // `Some(_)` regardless of the wrapped value. Pinned so a
3558 // future "helpful" canonicalization that treats
3559 // `Some(String::new())` as `None` at the primitive lands as
3560 // a compiler-visible failure here rather than as silent
3561 // operator-facing skew between the two spawn-slot-slug seeds.
3562 let mut p = pool_named("attest-pool");
3563 p.metadata.uid = Some(String::new());
3564 p.metadata.name = Some("attest-pool".into());
3565 assert_eq!(p.owned_uid_or_name_or_empty(), String::new());
3566 assert_ne!(p.owned_uid_or_name_or_empty(), "attest-pool");
3567 }
3568
3569 #[test]
3570 fn owned_uid_or_name_or_empty_is_a_pure_projection() {
3571 // Consecutive calls return byte-identical Strings across the
3572 // FULL corner set (uid-present, uid-absent name-fallback,
3573 // both-absent empty-sentinel) — no cached state, no mutation
3574 // on the `EphemeralPool` between calls. Peer to the sibling
3575 // `owned_name_or_empty_is_a_pure_projection` +
3576 // `owned_namespace_or_empty_is_a_pure_projection` pins in
3577 // this module; all three bind the pure-projection discipline
3578 // on the ONE substrate accessor per metadata-derived slot.
3579 let mut p = pool_named("attest-pool");
3580 p.metadata.uid = Some("uid-42".into());
3581 assert_eq!(
3582 p.owned_uid_or_name_or_empty(),
3583 p.owned_uid_or_name_or_empty()
3584 );
3585 p.metadata.uid = None;
3586 assert_eq!(
3587 p.owned_uid_or_name_or_empty(),
3588 p.owned_uid_or_name_or_empty()
3589 );
3590 p.metadata.name = None;
3591 assert_eq!(
3592 p.owned_uid_or_name_or_empty(),
3593 p.owned_uid_or_name_or_empty()
3594 );
3595 }
3596
3597 #[test]
3598 fn owned_uid_or_name_or_empty_matches_pre_lift_chain_verbatim() {
3599 // Byte-identical parity with the two hand-authored
3600 // `.metadata.uid.clone().unwrap_or_else(|| name.<into>())`
3601 // chains the primitive replaces in
3602 // `tatara-pool-reconciler::controller_pool` (spawn arm +
3603 // apply_convergence_actions arm). Runs across the FULL
3604 // corner set of the paired (metadata.uid, metadata.name)
3605 // slots. A regression that inserted a normalization step at
3606 // the primitive the pre-lift chain does NOT apply — or vice
3607 // versa — surfaces here rather than as silent drift between
3608 // the two owned-form callsites and the ONE substrate owner
3609 // they now route through.
3610 let cases: [(Option<String>, Option<String>, &str); 6] = [
3611 (Some("uid-42".into()), Some("attest-pool".into()), "uid-42"),
3612 (Some("uid-42".into()), None, "uid-42"),
3613 (Some(String::new()), Some("attest-pool".into()), ""),
3614 (None, Some("attest-pool".into()), "attest-pool"),
3615 (None, Some(String::new()), ""),
3616 (None, None, ""),
3617 ];
3618 for (uid_slot, name_slot, expected) in cases {
3619 let mut p = pool_named("attest-pool");
3620 p.metadata.uid = uid_slot.clone();
3621 p.metadata.name = name_slot.clone();
3622 // Reproduce the pre-lift chain shape at the spawn arm
3623 // (fallback `|| name.clone()` on an extracted-earlier
3624 // `String` name) — semantically equivalent to
3625 // `.metadata.name.clone().unwrap_or_default()` at the
3626 // point of call because `owned_coordinates_required()?`
3627 // gate guarantees the caller's `name` binding matches
3628 // the pool's own `metadata.name` slot.
3629 let pre_lift = p
3630 .metadata
3631 .uid
3632 .clone()
3633 .unwrap_or_else(|| p.metadata.name.clone().unwrap_or_default());
3634 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
3635 assert_eq!(p.owned_uid_or_name_or_empty(), pre_lift);
3636 assert_eq!(p.owned_uid_or_name_or_empty().as_str(), expected);
3637 }
3638 }
3639
3640 #[test]
3641 fn owned_uid_or_name_or_empty_composes_with_member_process_name_seed_shape() {
3642 // Composition pin: the produced owned `String` feeds the
3643 // downstream `member_process_name(&pool_name, &pool_uid_or_
3644 // name_fallback, slot)` composer at both callsites, so the
3645 // seed's `String` shape must survive being borrowed as
3646 // `&str` for the composer without any owned/borrow-form
3647 // adaptation at the callsite. Binds the primitive's return
3648 // type + the borrow-form availability that the pre-lift
3649 // chain also produced (a locally-owned `String` from
3650 // `.clone().unwrap_or_else(|| name.<into>())`).
3651 let mut p = pool_named("attest-pool");
3652 p.metadata.uid = Some("uid-42".into());
3653 let seed: String = p.owned_uid_or_name_or_empty();
3654 let _borrowed: &str = &seed;
3655 assert_eq!(seed, "uid-42");
3656 p.metadata.uid = None;
3657 let seed_fallback: String = p.owned_uid_or_name_or_empty();
3658 let _borrowed_fallback: &str = &seed_fallback;
3659 assert_eq!(seed_fallback, "attest-pool");
3660 }
3661
3662 // ─── AllocationRef::new substrate pins ────────────────────────────
3663 //
3664 // Pins the substrate constructor for [`AllocationRef`] — the
3665 // ONE-liner composer that lifts the paired
3666 // `AllocationRef { name, namespace }` struct-literal every
3667 // downstream consumer restated by hand pre-lift at FOUR production
3668 // sites (2 × controller_allocation.rs assignedProcess seeds, 1 ×
3669 // allocation_decide.rs pool_ref seed, 1 × allocation_factory.rs
3670 // pool_ref seed) onto ONE substrate owner on `AllocationRef`.
3671 // Fail-before-pass-after granularity: `AllocationRef::new` did not
3672 // exist pre-lift; the compiler cannot resolve the name until the
3673 // impl block above is in place, so a rollback of the primitive
3674 // breaks this whole module.
3675
3676 #[test]
3677 fn allocation_ref_new_composes_owned_string_pair_verbatim() {
3678 // Happy-path pin: the constructor materializes an
3679 // `AllocationRef { name: <name>, namespace: <namespace> }`
3680 // byte-identical to the pre-lift struct literal every consumer
3681 // spelled. A regression that dropped either slot (e.g. an
3682 // erroneous `..Default::default()` on a shape that never had
3683 // a Default derive) surfaces here rather than as silent slot
3684 // loss downstream at the assignedProcess / bound_pool /
3685 // matched_pool / spec.pool_ref sinks.
3686 let r = AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
3687 assert_eq!(r.name, "pr-42-demo");
3688 assert_eq!(r.namespace, "ephemeral-pools");
3689 }
3690
3691 #[test]
3692 fn allocation_ref_new_matches_pre_lift_struct_literal_verbatim() {
3693 // Byte-identical parity pin: the substrate constructor and the
3694 // hand-authored struct literal produce equal `AllocationRef`
3695 // values on every provenance the FOUR pre-lift sites carried
3696 // (owned `String` from an owned-form projection; `&str`
3697 // promoted through `.to_string()`). A regression that inserted
3698 // a normalization step at the primitive the pre-lift literal
3699 // does NOT apply — or vice versa — surfaces here rather than
3700 // as silent drift between the four consumers and the ONE
3701 // substrate owner they now route through.
3702 let owned_name = String::from("pr-42-demo");
3703 let owned_ns = String::from("ephemeral-pools");
3704 let lifted = AllocationRef::new(owned_name.clone(), owned_ns.clone());
3705 let pre_lift = AllocationRef {
3706 name: owned_name,
3707 namespace: owned_ns,
3708 };
3709 assert_eq!(lifted, pre_lift);
3710 }
3711
3712 #[test]
3713 fn allocation_ref_new_accepts_str_provenance_via_into_string() {
3714 // `Into<String>` provenance-closure pin: the primitive accepts
3715 // every provenance the pre-lift sites carried. The
3716 // controller_allocation.rs assignedProcess seeds passed owned
3717 // `String` values (a moved `member_process_name` +
3718 // `ns.clone()`); the allocation_factory.rs pool_ref seed
3719 // passed `&str` (`n.to_string()` / `namespace.to_string()`).
3720 // Both provenances produce byte-identical output. A future
3721 // refactor of the constructor signature that demanded owned
3722 // `String` at author sites (dropping `impl Into<String>`)
3723 // would force `.to_string()` back at the FOUR call sites — the
3724 // pin fences that regression at ONE place.
3725 let from_str = AllocationRef::new("pr-42-demo", "ephemeral-pools");
3726 let from_string =
3727 AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
3728 assert_eq!(from_str, from_string);
3729 // Mixed provenance is also load-bearing: the allocation_decide.rs
3730 // matched_pool seed pairs an owned `String` (from
3731 // `EphemeralPool::owned_name_or_empty()`) with a hand-authored
3732 // `.clone().unwrap_or_default()` — also `String`. The
3733 // controller_allocation.rs paths pair a moved `String` name
3734 // with a `.clone()`-ed `ns: String`. Verify (owned, borrow)
3735 // and (borrow, owned) both compose to the same shape as
3736 // (owned, owned) / (borrow, borrow).
3737 let mixed_a = AllocationRef::new(String::from("pr-42-demo"), "ephemeral-pools");
3738 let mixed_b = AllocationRef::new("pr-42-demo", String::from("ephemeral-pools"));
3739 assert_eq!(from_str, mixed_a);
3740 assert_eq!(from_str, mixed_b);
3741 }
3742
3743 #[test]
3744 fn allocation_ref_new_positional_axis_order_pinned_name_first_namespace_second() {
3745 // Axis-order pin: name is the FIRST positional argument;
3746 // namespace is the SECOND. Reversing the pair at the
3747 // constructor is the exact regression this pin fences — the
3748 // FOUR pre-lift sites all spelled `name` before `namespace`
3749 // (matching the struct definition's field order in
3750 // `pub struct AllocationRef { pub name, pub namespace }`)
3751 // and the wire-format serde output `{ "name": "...",
3752 // "namespace": "..." }` reflects that order. A slot swap at
3753 // the primitive would surface here rather than as silent
3754 // `<namespace>/<name>` inversion at every downstream
3755 // qualified-ref composer that reads `{ref.name}/{ref.namespace}`
3756 // as an audit-log key.
3757 let r = AllocationRef::new("alpha-name", "beta-namespace");
3758 assert_eq!(r.name, "alpha-name");
3759 assert_eq!(r.namespace, "beta-namespace");
3760 assert_ne!(r.name, "beta-namespace");
3761 assert_ne!(r.namespace, "alpha-name");
3762 }
3763
3764 #[test]
3765 fn allocation_ref_new_preserves_empty_string_verbatim() {
3766 // Empty-string sentinel pin: the constructor is pure — it does
3767 // NOT canonicalize empty inputs (does NOT default an empty
3768 // namespace to `"default"`; does NOT reject an empty name).
3769 // Preserves the pre-lift shape the allocation_decide.rs
3770 // matched_pool seed relied on: when the pool's metadata.namespace
3771 // is absent, `.clone().unwrap_or_default()` yields the empty
3772 // string, and the AllocationRef's namespace slot carries that
3773 // empty string verbatim to the downstream `bound_pool` sink.
3774 // A future canonicalization pass (e.g. defaulting to
3775 // `Process::DEFAULT_NAMESPACE`) MUST land here, not at the
3776 // primitive body silently, so the pre-lift consumers' empty-
3777 // sentinel semantics are the visible contract of the new
3778 // constructor.
3779 let r = AllocationRef::new("", "");
3780 assert_eq!(r.name, "");
3781 assert_eq!(r.namespace, "");
3782 let mixed = AllocationRef::new("pr-42-demo", "");
3783 assert_eq!(mixed.name, "pr-42-demo");
3784 assert_eq!(mixed.namespace, "");
3785 }
3786
3787 #[test]
3788 fn allocation_ref_new_composes_with_owned_name_or_empty_pool_projection() {
3789 // Composition pin: the constructor composes with the paired
3790 // substrate primitives [`EphemeralPool::owned_name_or_empty`]
3791 // + [`EphemeralPool::owned_namespace_or_empty`] at the
3792 // allocation_decide.rs pool_ref seed — the same primitive
3793 // family the pool CRD opened for both halves of the
3794 // `AllocationRef { name, namespace }` struct literal. The
3795 // composed pair carries an owned `String` name half (from
3796 // `pool.owned_name_or_empty()`) and an owned `String`
3797 // namespace half (from `pool.owned_namespace_or_empty()`) —
3798 // no pre-lift chain remains. A regression that broke the
3799 // primitive family's `impl Into<String>` acceptance of an
3800 // owned `String` return type would surface here rather than
3801 // as silent build failure at the pool-reconciler matched_pool
3802 // seed.
3803 let pool = pool_named("attest-pool");
3804 let r = AllocationRef::new(pool.owned_name_or_empty(), pool.owned_namespace_or_empty());
3805 assert_eq!(r.name, "attest-pool");
3806 assert_eq!(r.namespace, pool.owned_namespace_or_empty());
3807 }
3808
3809 #[test]
3810 fn allocation_ref_new_returns_wire_format_serialization_verbatim() {
3811 // Wire-format pin: the constructor produces an
3812 // [`AllocationRef`] whose serde `rename_all = "camelCase"`
3813 // serialization is byte-identical to the pre-lift struct
3814 // literal's serialization. The `bound_pool` and
3815 // `assignedProcess` slots on `AllocationStatus` (and the
3816 // `poolRef` slot on `AllocationSpec`) all round-trip through
3817 // this shape — the pin fences a regression that added a
3818 // private field or a `#[serde(skip)]` accidentally.
3819 let r = AllocationRef::new("pr-42-demo", "ephemeral-pools");
3820 let yaml = serde_yaml::to_string(&r).expect("AllocationRef serializes to yaml");
3821 assert!(yaml.contains("name: pr-42-demo"), "{yaml}");
3822 assert!(yaml.contains("namespace: ephemeral-pools"), "{yaml}");
3823 let back: AllocationRef =
3824 serde_yaml::from_str(&yaml).expect("AllocationRef round-trips through yaml");
3825 assert_eq!(back, r);
3826 }
3827
3828 fn member(state: MemberState) -> PoolMember {
3829 // 4-slot unallocated seed rides through the ONE substrate
3830 // owner `PoolMember::unallocated` (peer of the four workspace-
3831 // wide restatements of the SAME `PoolMember { process_name,
3832 // state, entered_state_at, allocation_ref: None }` fixture
3833 // literal that pre-lift lived at the production `controller_
3834 // pool::reconcile_inner` walk + the two `pool_decide::tests::
3835 // member` / `allocation_decide::tests::member` helpers + the
3836 // sibling `named_member` helper in this file).
3837 PoolMember::unallocated("m", state, crate::time::at_epoch_second(0))
3838 }
3839
3840 #[test]
3841 fn state_count_fanout_returns_all_zeros_on_empty_slice() {
3842 // Zero-length pin: the empty-members corner produces a
3843 // 4-tuple of zero counters, matching the pre-lift
3844 // `count_state` fanout's four `.iter().filter(...).count()`
3845 // calls each returning 0 on an empty iterator.
3846 assert_eq!(PoolMember::state_count_fanout(&[]), (0, 0, 0, 0));
3847 }
3848
3849 #[test]
3850 fn state_count_fanout_partitions_variants_into_correct_slots() {
3851 // Positional-axis pin: the returned 4-tuple's slot order
3852 // matches the four `PoolStatus` counter slots in declaration
3853 // order — `(ready, allocated, spawning, returning)`. A
3854 // regression that swapped two slots (e.g., `ready` ↔
3855 // `spawning`) surfaces here rather than as an operator-facing
3856 // scale-out oscillation at the pool reconciler.
3857 let members = vec![
3858 member(MemberState::Free),
3859 member(MemberState::Free),
3860 member(MemberState::Allocated),
3861 member(MemberState::Spawning),
3862 member(MemberState::Spawning),
3863 member(MemberState::Spawning),
3864 member(MemberState::Returning),
3865 ];
3866 assert_eq!(PoolMember::state_count_fanout(&members), (2, 1, 3, 1));
3867 }
3868
3869 #[test]
3870 fn state_count_fanout_excludes_failed_from_every_counter() {
3871 // Closed-set pin: no `PoolStatus` slot counts `Failed` members
3872 // (they surface via `PoolPhase::Degraded` instead of a status
3873 // counter). This test fences a regression that let a `Failed`
3874 // member drift into one of the four counters and inflate the
3875 // operator-visible ready/allocated/spawning/returning fanout.
3876 let members = vec![
3877 member(MemberState::Failed),
3878 member(MemberState::Failed),
3879 member(MemberState::Failed),
3880 ];
3881 assert_eq!(PoolMember::state_count_fanout(&members), (0, 0, 0, 0));
3882
3883 // Mixed with a Free member: the Free member is counted, the
3884 // Failed members are not.
3885 let mixed = vec![
3886 member(MemberState::Free),
3887 member(MemberState::Failed),
3888 member(MemberState::Failed),
3889 ];
3890 assert_eq!(PoolMember::state_count_fanout(&mixed), (1, 0, 0, 0));
3891 }
3892
3893 #[test]
3894 fn state_count_fanout_matches_pre_lift_count_state_helper_verbatim() {
3895 // Parity pin: for every possible members list, the 4-tuple
3896 // returned by the substrate primitive matches the pre-lift
3897 // `count_state(&members, MemberState::<slot>)` fanout that
3898 // pool-reconciler restated at both status-patch sites. The
3899 // pre-lift helper was
3900 // ```rust,ignore
3901 // fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
3902 // members.iter().filter(|m| m.state == target).count() as u32
3903 // }
3904 // ```
3905 // — re-implemented inline here as an oracle.
3906 fn count_state(members: &[PoolMember], target: MemberState) -> u32 {
3907 members.iter().filter(|m| m.state == target).count() as u32
3908 }
3909 let members = vec![
3910 member(MemberState::Free),
3911 member(MemberState::Allocated),
3912 member(MemberState::Allocated),
3913 member(MemberState::Spawning),
3914 member(MemberState::Returning),
3915 member(MemberState::Returning),
3916 member(MemberState::Failed),
3917 ];
3918 let (ready, allocated, spawning, returning) = PoolMember::state_count_fanout(&members);
3919 assert_eq!(ready, count_state(&members, MemberState::Free));
3920 assert_eq!(allocated, count_state(&members, MemberState::Allocated));
3921 assert_eq!(spawning, count_state(&members, MemberState::Spawning));
3922 assert_eq!(returning, count_state(&members, MemberState::Returning));
3923 }
3924
3925 // ─── PoolMember::process_names_set substrate pins ─────────────────
3926 //
3927 // Pins the closed-set slice-owned collection primitive on the
3928 // `process_name` axis into a `HashSet<String>` — the O(1)-lookup
3929 // shape both spawn arms in
3930 // `tatara-pool-reconciler::controller_pool` build pre-collision-
3931 // check against a candidate `member_process_name(&pool_name,
3932 // &pool_uid, slot)`. Sibling to `state_count_fanout` on the
3933 // `(collection shape × slice-owned fold)` axis; the fanout owns
3934 // the state-counter tuple corner, this primitive owns the
3935 // process-name-lookup corner. Fail-before-pass-after granularity:
3936 // `process_names_set` did not exist pre-lift; the compiler cannot
3937 // resolve the name until the impl block above is in place, so a
3938 // rollback of the primitive breaks this whole test group.
3939
3940 fn named_member(process_name: &str, state: MemberState) -> PoolMember {
3941 // 4-slot unallocated seed rides through the ONE substrate
3942 // owner `PoolMember::unallocated` — sibling to the `member`
3943 // helper in this file on the same epoch-anchored axis.
3944 PoolMember::unallocated(process_name, state, crate::time::at_epoch_second(0))
3945 }
3946
3947 #[test]
3948 fn process_names_set_returns_empty_hashset_on_empty_slice() {
3949 // Zero-length pin: the empty-members corner produces an
3950 // empty `HashSet<String>`, matching the pre-lift
3951 // `.iter().map(...).collect()` chain's empty-iterator
3952 // behavior. A regression that started producing a sentinel
3953 // entry (a `""` placeholder, a static seed) on the empty-
3954 // slice corner would silently reject the first spawn slot
3955 // downstream — the pin closes that failure mode.
3956 let empty: Vec<PoolMember> = vec![];
3957 assert!(PoolMember::process_names_set(&empty).is_empty());
3958 }
3959
3960 #[test]
3961 fn process_names_set_collects_every_process_name_from_populated_slice() {
3962 // Positive pin: every `PoolMember`'s `process_name` slot
3963 // lands in the returned `HashSet<String>` verbatim. Cross-
3964 // state (Free / Allocated / Spawning / Returning / Failed)
3965 // to prove the primitive is state-agnostic — the spawn arms
3966 // check occupancy on the name axis, NOT the state axis, so a
3967 // future refactor that filtered by state would silently
3968 // leave a returned/failed slot open to a duplicate spawn.
3969 let members = vec![
3970 named_member("pool-a-0", MemberState::Free),
3971 named_member("pool-a-1", MemberState::Allocated),
3972 named_member("pool-a-2", MemberState::Spawning),
3973 named_member("pool-a-3", MemberState::Returning),
3974 named_member("pool-a-4", MemberState::Failed),
3975 ];
3976 let set = PoolMember::process_names_set(&members);
3977 assert_eq!(set.len(), 5);
3978 for slot in 0..5 {
3979 let want = format!("pool-a-{slot}");
3980 assert!(set.contains(&want), "missing {want}; set = {set:?}");
3981 }
3982 }
3983
3984 #[test]
3985 fn process_names_set_deduplicates_duplicate_process_names() {
3986 // Deduplication pin: two `PoolMember` entries with the same
3987 // `process_name` (a race between the two spawn arms, an
3988 // adopted foreign Process the reconciler picked up twice)
3989 // collapse to ONE entry in the `HashSet<String>`. Pins the
3990 // `HashSet` deduplication semantics the pre-lift `.iter()
3991 // .map(...).collect()` chain already inherited from the
3992 // `FromIterator` impl — a regression that swapped the
3993 // aggregate to a `Vec<String>` or `BTreeSet<String>` still
3994 // matches the shape but changes the operator-visible count
3995 // at the `.len()` probe here.
3996 let members = vec![
3997 named_member("pool-b-0", MemberState::Free),
3998 named_member("pool-b-0", MemberState::Spawning),
3999 named_member("pool-b-1", MemberState::Free),
4000 ];
4001 let set = PoolMember::process_names_set(&members);
4002 assert_eq!(set.len(), 2);
4003 assert!(set.contains("pool-b-0"));
4004 assert!(set.contains("pool-b-1"));
4005 }
4006
4007 #[test]
4008 fn process_names_set_membership_probe_matches_pre_lift_chain_verbatim() {
4009 // Byte-identical parity pin: the `.contains(&candidate)`
4010 // probe on the substrate's `HashSet<String>` return returns
4011 // the same `bool` as the pre-lift `members.iter().map(|m|
4012 // m.process_name.clone()).collect::<HashSet<_>>().contains
4013 // (&candidate)` chain across the FULL cross product of
4014 // (candidate ∈ {an existing name, a novel name, the empty
4015 // string}). A regression that inserted a normalization step
4016 // at the primitive the pre-lift chain does NOT apply — or
4017 // vice versa — surfaces here rather than as silent drift
4018 // between the two spawn arms the primitive owns.
4019 let members = vec![
4020 named_member("pool-c-0", MemberState::Free),
4021 named_member("pool-c-1", MemberState::Allocated),
4022 ];
4023 let candidates: [&str; 4] = ["pool-c-0", "pool-c-1", "pool-c-2", ""];
4024 let via_primitive = PoolMember::process_names_set(&members);
4025 for candidate in candidates {
4026 let pre_lift: std::collections::HashSet<String> =
4027 members.iter().map(|m| m.process_name.clone()).collect();
4028 assert_eq!(
4029 via_primitive.contains(candidate),
4030 pre_lift.contains(candidate),
4031 "candidate = {candidate:?}"
4032 );
4033 }
4034 }
4035
4036 #[test]
4037 fn process_names_set_is_a_pure_projection() {
4038 // Consecutive calls on the same slice return equal sets —
4039 // no cached state, no mutation on the input. Guards against
4040 // a future refactor that plants a cache field somewhere and
4041 // drifts one caller from another silently.
4042 let members = vec![
4043 named_member("pool-d-0", MemberState::Free),
4044 named_member("pool-d-1", MemberState::Spawning),
4045 ];
4046 let first = PoolMember::process_names_set(&members);
4047 let second = PoolMember::process_names_set(&members);
4048 assert_eq!(first, second);
4049 }
4050
4051 #[test]
4052 fn pool_status_observed_composes_pre_lift_status_seed_verbatim() {
4053 // Composition pin: the substrate constructor produces a
4054 // `PoolStatus` structurally equal to the pre-lift 11-line
4055 // struct literal both pool-reconciler status-patch sites
4056 // stamped by hand. Any drift in the defaults (`message`,
4057 // `conditions`) or in the counter fanout surfaces here.
4058 let now = crate::time::at_epoch_second(1_700_000_000);
4059 let members = vec![
4060 member(MemberState::Free),
4061 member(MemberState::Allocated),
4062 member(MemberState::Spawning),
4063 member(MemberState::Returning),
4064 member(MemberState::Failed),
4065 ];
4066 let member_count = members.len();
4067 let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
4068 assert_eq!(observed.phase, PoolPhase::Steady);
4069 assert_eq!(observed.phase_since, Some(now));
4070 assert_eq!(observed.ready_count, 1);
4071 assert_eq!(observed.allocated_count, 1);
4072 assert_eq!(observed.spawning_count, 1);
4073 assert_eq!(observed.returning_count, 1);
4074 assert_eq!(observed.members.len(), member_count);
4075 assert!(observed.message.is_none());
4076 assert!(observed.conditions.is_empty());
4077 }
4078
4079 #[test]
4080 fn pool_status_observed_moves_members_by_value_without_extra_clone() {
4081 // Ownership pin: the constructor consumes the members Vec by
4082 // value rather than borrowing + cloning internally. Both pre-
4083 // lift sites called `.clone()` on their `members` binding for
4084 // the struct-literal `members:` slot; the substrate lift keeps
4085 // the same one-clone bound at the caller (or a straight move
4086 // if the caller no longer needs the local `members` binding
4087 // after the seed) rather than accidentally cloning twice.
4088 let members = vec![member(MemberState::Free), member(MemberState::Spawning)];
4089 let now = crate::time::at_epoch_second(0);
4090 let observed = PoolStatus::observed(PoolPhase::Steady, members, now);
4091 assert_eq!(observed.members.len(), 2);
4092 }
4093
4094 // ─── PoolStatus::observed_now substrate pins ─────────────────────
4095 //
4096 // Bind [`PoolStatus::observed_now`] at fail-before-pass-after
4097 // granularity so a regression that dropped the wall-clock read
4098 // (yielding a `phase_since` of `Some(DateTime::default())`),
4099 // reshaped the delegation target (a peer 4-arg composer that
4100 // stamped different defaults), or diverged the peer from the 3-arg
4101 // [`PoolStatus::observed`] on any observable slot surfaces HERE
4102 // rather than as silent operator-facing drift at the two
4103 // controller_pool status-patch sites.
4104 //
4105 // Each pin is fail-before-pass-after: the primitive did not exist
4106 // pre-lift, so any test that invokes it fails to compile pre-lift
4107 // and passes post-lift; the byte-identity pins below then bind the
4108 // specific shape choice.
4109
4110 #[test]
4111 fn pool_status_observed_now_composes_through_observed_with_wall_clock() {
4112 // Composition pin: `observed_now` MUST agree with the 3-arg
4113 // `observed(phase, members, Utc::now())` peer at every slot
4114 // other than `phase_since` (which reads the wall clock at
4115 // different instants and diverges by scheduler jitter). A
4116 // regression that specialized either composer (a stray
4117 // canonicalization at `observed_now`, a swapped default at the
4118 // 3-arg peer) would surface HERE rather than as silent skew at
4119 // the two controller_pool sites the primitive owns.
4120 let members = vec![
4121 member(MemberState::Free),
4122 member(MemberState::Allocated),
4123 member(MemberState::Spawning),
4124 member(MemberState::Returning),
4125 ];
4126 let via_now = PoolStatus::observed_now(PoolPhase::Steady, members.clone());
4127 let via_injected =
4128 PoolStatus::observed(PoolPhase::Steady, members.clone(), chrono::Utc::now());
4129 assert_eq!(via_now.phase, via_injected.phase);
4130 assert_eq!(via_now.ready_count, via_injected.ready_count);
4131 assert_eq!(via_now.allocated_count, via_injected.allocated_count);
4132 assert_eq!(via_now.spawning_count, via_injected.spawning_count);
4133 assert_eq!(via_now.returning_count, via_injected.returning_count);
4134 assert_eq!(via_now.members.len(), via_injected.members.len());
4135 assert_eq!(via_now.message, via_injected.message);
4136 assert_eq!(via_now.conditions.len(), via_injected.conditions.len());
4137 }
4138
4139 #[test]
4140 fn pool_status_observed_now_reads_wall_clock_into_phase_since() {
4141 // Wall-clock pin: `phase_since` MUST fall between `Utc::now()`
4142 // reads bracketed around the call. A regression that dropped
4143 // the wall-clock read to a module-load constant (`Utc::now()`
4144 // captured at `static` init), a `DateTime::default()` (epoch),
4145 // or a stale `None` would fail this bracket check.
4146 let before = chrono::Utc::now();
4147 let observed = PoolStatus::observed_now(PoolPhase::Steady, vec![]);
4148 let after = chrono::Utc::now();
4149 let phase_since = observed
4150 .phase_since
4151 .expect("observed_now must stamp phase_since with the wall clock");
4152 assert!(
4153 phase_since >= before && phase_since <= after,
4154 "phase_since {phase_since} must fall in [{before}, {after}]"
4155 );
4156 }
4157
4158 #[test]
4159 fn pool_status_observed_now_stamps_the_same_defaults_as_the_injected_peer() {
4160 // Defaults pin: `message: None` + `conditions: vec![]` MUST
4161 // agree with the 3-arg [`PoolStatus::observed`] peer verbatim.
4162 // A regression that stamped a per-caller message default at
4163 // `observed_now` (a "wall-clock-stamped observation" prefix,
4164 // say) or seeded a "just-observed" Condition row would surface
4165 // HERE rather than as silent operator-facing drift at either
4166 // status-patch site.
4167 let observed = PoolStatus::observed_now(PoolPhase::Steady, vec![]);
4168 assert!(observed.message.is_none());
4169 assert!(observed.conditions.is_empty());
4170 }
4171
4172 #[test]
4173 fn pool_status_observed_now_wall_clock_is_read_per_invocation_not_cached() {
4174 // Monotonic-read pin: two back-to-back `observed_now` calls
4175 // MUST read `Utc::now()` twice — the second `phase_since` MUST
4176 // be `>=` the first. A regression that cached a wall-clock read
4177 // into a `OnceLock` / lazy `static` would fire the SAME
4178 // `phase_since` for every caller on the reconciler's process
4179 // and every status-patch would carry the module-load instant
4180 // rather than the tick instant. Both instants may coincide on
4181 // a fast machine; use `>=` (not `>`) to keep the pin robust
4182 // against subsecond scheduler granularity while still catching
4183 // a cached-constant regression (where the second read would
4184 // be < the wall clock).
4185 let first = PoolStatus::observed_now(PoolPhase::Steady, vec![])
4186 .phase_since
4187 .expect("first observed_now stamps phase_since");
4188 let second = PoolStatus::observed_now(PoolPhase::Steady, vec![])
4189 .phase_since
4190 .expect("second observed_now stamps phase_since");
4191 assert!(
4192 second >= first,
4193 "second phase_since {second} must be >= first phase_since {first}"
4194 );
4195 // AND the second read MUST NOT precede the wall clock reads
4196 // bracketing the call — a cached-past constant would fail
4197 // this bound.
4198 let after = chrono::Utc::now();
4199 assert!(
4200 second <= after,
4201 "second phase_since {second} must be <= {after}"
4202 );
4203 }
4204
4205 #[test]
4206 fn pool_status_observed_now_matches_pre_lift_utc_now_composition_shape() {
4207 // Byte-identical parity with the pre-lift
4208 // `PoolStatus::observed(phase, members.clone(), Utc::now())`
4209 // block both hand-authored callsites restated at their status-
4210 // patch sites, swept across representative pool-phase variants.
4211 // Both blocks read the wall clock at DIFFERENT instants so the
4212 // two anchors CAN differ by the wall-clock delta between calls
4213 // — bound the divergence at 100ms scheduler jitter, matching
4214 // the peer `seconds_ago_matches_hand_authored_pre_lift_chain_shape`
4215 // pin's tolerance on the sibling `crate::time` module.
4216 let members = vec![member(MemberState::Free), member(MemberState::Spawning)];
4217 for phase in [PoolPhase::Steady, PoolPhase::ScalingUp, PoolPhase::Degraded] {
4218 let composed = PoolStatus::observed_now(phase, members.clone())
4219 .phase_since
4220 .expect("observed_now stamps phase_since");
4221 let hand_authored = PoolStatus::observed(phase, members.clone(), chrono::Utc::now())
4222 .phase_since
4223 .expect("observed stamps phase_since");
4224 let delta = (hand_authored - composed).abs();
4225 assert!(
4226 delta <= chrono::Duration::milliseconds(100),
4227 "composed {composed} and hand-authored {hand_authored} must agree within 100ms scheduler jitter for phase={phase:?}"
4228 );
4229 }
4230 }
4231
4232 // ─── EphemeralPool::observed_phase_from substrate pins ───────────
4233 //
4234 // Pins the pure typed projection at fail-before-pass-after
4235 // granularity: `observed_phase_from` did not exist on
4236 // `EphemeralPool` pre-lift — the gate ladder lived at
4237 // `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
4238 // as a repo-internal free function. Any test that invokes
4239 // `pool.observed_phase_from(&members)` fails to compile pre-lift
4240 // and passes post-lift; the truth-table pins below then bind the
4241 // ladder's five gate corners individually so a regression that
4242 // reordered any two gates, dropped an arm, or drifted a threshold
4243 // surfaces per-corner rather than as silent operator-facing skew
4244 // at the two `controller_pool` status-patch callsites the
4245 // downstream compound composer `PoolStatus::observed_from`
4246 // delegates through.
4247
4248 fn pool_with_desired_and_min(desired: u32, min: u32) -> EphemeralPool {
4249 let spec = PoolSpec {
4250 desired_size: desired,
4251 min_size: min,
4252 ..PoolSpec::with_template(empty_template())
4253 };
4254 EphemeralPool::new("attest-pool", spec)
4255 }
4256
4257 #[test]
4258 fn observed_phase_from_returns_draining_on_tombstoned_pool_regardless_of_supply() {
4259 // Tombstone-first gate pin: a tombstoned pool MUST return
4260 // `Draining` regardless of `spec.min_size`, `spec.desired_size`,
4261 // and the member supply. A regression that let the supply
4262 // arithmetic pre-empt the tombstone probe would silently
4263 // classify a draining pool as `Steady` / `ScalingUp` /
4264 // `Degraded` and hide the deletion-in-flight state from
4265 // operators reading `kubectl get ephemeralpools`.
4266 let p = tombstoned_pool();
4267 // Every supply corner must yield the same `Draining` answer.
4268 for members in [
4269 vec![],
4270 vec![member(MemberState::Free)],
4271 vec![
4272 member(MemberState::Free),
4273 member(MemberState::Spawning),
4274 member(MemberState::Allocated),
4275 ],
4276 vec![member(MemberState::Failed)],
4277 ] {
4278 assert_eq!(
4279 p.observed_phase_from(&members),
4280 PoolPhase::Draining,
4281 "tombstoned pool must project Draining regardless of members={members:?}",
4282 );
4283 }
4284 }
4285
4286 #[test]
4287 fn observed_phase_from_returns_initializing_on_empty_members() {
4288 // Empty-members gate pin: an untombstoned pool with zero
4289 // members MUST return `Initializing`, regardless of
4290 // `spec.desired_size` and `spec.min_size`. A regression that
4291 // let the min-floor gate fire on empty members (supply = 0 <
4292 // min_size) would misreport the fresh-pool state as
4293 // `Degraded` and trip any downstream health aggregator that
4294 // treats `Degraded` as an alertable state.
4295 for (desired, min) in [(0, 0), (1, 0), (3, 1), (5, 3)] {
4296 let p = pool_with_desired_and_min(desired, min);
4297 assert_eq!(
4298 p.observed_phase_from(&[]),
4299 PoolPhase::Initializing,
4300 "empty members must project Initializing for (desired={desired}, min={min})",
4301 );
4302 }
4303 }
4304
4305 #[test]
4306 fn observed_phase_from_returns_degraded_when_supply_below_min_size() {
4307 // Min-floor gate pin: `min_size > 0 && supply < min_size` MUST
4308 // fire `Degraded` before the supply-vs-desired gate gets a
4309 // chance to pick `ScalingUp` / `ScalingDown` / `Steady`. Note
4310 // the guard `min_size > 0` — a pool with `min_size = 0` never
4311 // trips this gate even at zero supply. Sweep the (min, supply)
4312 // corners that plausibly reach the reconciler at tick time.
4313 let p = pool_with_desired_and_min(5, 2);
4314 // supply = 0 → 1 Allocated (does not count) + 0 Free/Spawning
4315 let members = vec![member(MemberState::Allocated)];
4316 assert_eq!(p.observed_phase_from(&members), PoolPhase::Degraded);
4317 // supply = 1 (< min_size = 2) → still Degraded even though
4318 // supply < desired (5) would otherwise pick ScalingUp.
4319 let members = vec![
4320 member(MemberState::Free),
4321 member(MemberState::Allocated),
4322 member(MemberState::Allocated),
4323 ];
4324 assert_eq!(p.observed_phase_from(&members), PoolPhase::Degraded);
4325 }
4326
4327 #[test]
4328 fn observed_phase_from_returns_scaling_up_when_supply_below_desired() {
4329 // Supply-vs-desired gate pin (up arm): `supply < desired_size`
4330 // and no min-floor breach → `ScalingUp`. The reconciler's
4331 // convergence loop is expected to spawn additional members to
4332 // close the gap.
4333 let p = pool_with_desired_and_min(3, 0);
4334 let members = vec![
4335 member(MemberState::Free),
4336 member(MemberState::Spawning),
4337 member(MemberState::Allocated),
4338 ];
4339 assert_eq!(p.observed_phase_from(&members), PoolPhase::ScalingUp);
4340 }
4341
4342 #[test]
4343 fn observed_phase_from_returns_scaling_down_when_supply_above_desired() {
4344 // Supply-vs-desired gate pin (down arm): `supply > desired_size`
4345 // → `ScalingDown`. The reconciler's convergence loop is
4346 // expected to reap excess Free members.
4347 let p = pool_with_desired_and_min(1, 0);
4348 let members = vec![
4349 member(MemberState::Free),
4350 member(MemberState::Free),
4351 member(MemberState::Spawning),
4352 ];
4353 assert_eq!(p.observed_phase_from(&members), PoolPhase::ScalingDown);
4354 }
4355
4356 #[test]
4357 fn observed_phase_from_returns_steady_when_supply_equals_desired() {
4358 // Terminal-arm pin: `supply == desired_size` with no tombstone,
4359 // no floor breach → `Steady`. This is the goal state the
4360 // reconciler drives the pool toward.
4361 let p = pool_with_desired_and_min(2, 0);
4362 let members = vec![
4363 member(MemberState::Free),
4364 member(MemberState::Spawning),
4365 member(MemberState::Allocated),
4366 ];
4367 // Free + Spawning count toward supply (Allocated does not) → 2.
4368 assert_eq!(p.observed_phase_from(&members), PoolPhase::Steady);
4369 }
4370
4371 #[test]
4372 fn observed_phase_from_excludes_failed_members_from_supply() {
4373 // Closed-set contract pin: `Failed` members MUST NOT count
4374 // toward supply (peer of the
4375 // `member_state_failed_implies_no_supply` contract on
4376 // `MemberState`). A regression that let `Failed` inflate the
4377 // supply count would silently satisfy the `supply >= min_size`
4378 // gate on a pool that's actually below floor and misclassify
4379 // the state as `Steady` / `ScalingUp` instead of `Degraded`.
4380 let p = pool_with_desired_and_min(2, 1);
4381 let members = vec![
4382 member(MemberState::Failed),
4383 member(MemberState::Failed),
4384 member(MemberState::Failed),
4385 ];
4386 // supply = 0 (no Free/Spawning) < min_size = 1 → Degraded.
4387 assert_eq!(p.observed_phase_from(&members), PoolPhase::Degraded);
4388 }
4389
4390 #[test]
4391 fn observed_phase_from_matches_pre_lift_reconciler_chain() {
4392 // Byte-identical parity with the pre-lift
4393 // `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
4394 // free function that the primitive absorbs. Runs across the
4395 // FULL corner set of the gate ladder (tombstone, empty, floor
4396 // breach, scaling up, scaling down, steady) so a regression
4397 // that drifted the gate ladder at the substrate surfaces here
4398 // per-corner rather than as silent operator-facing skew at the
4399 // two `controller_pool` status-patch callsites.
4400 fn pre_lift(pool: &EphemeralPool, members: &[PoolMember]) -> PoolPhase {
4401 if pool.is_being_deleted() {
4402 return PoolPhase::Draining;
4403 }
4404 let supply = members
4405 .iter()
4406 .filter(|m| m.state.counts_toward_supply())
4407 .count() as u32;
4408 let want = pool.spec.desired_size;
4409 if members.is_empty() {
4410 return PoolPhase::Initializing;
4411 }
4412 if pool.spec.min_size > 0 && supply < pool.spec.min_size {
4413 return PoolPhase::Degraded;
4414 }
4415 if supply < want {
4416 return PoolPhase::ScalingUp;
4417 }
4418 if supply > want {
4419 return PoolPhase::ScalingDown;
4420 }
4421 PoolPhase::Steady
4422 }
4423 let cases: [(EphemeralPool, Vec<PoolMember>); 6] = [
4424 (tombstoned_pool(), vec![member(MemberState::Free)]),
4425 (pool_with_desired_and_min(3, 0), vec![]),
4426 (
4427 pool_with_desired_and_min(5, 2),
4428 vec![member(MemberState::Allocated)],
4429 ),
4430 (
4431 pool_with_desired_and_min(3, 0),
4432 vec![member(MemberState::Free), member(MemberState::Spawning)],
4433 ),
4434 (
4435 pool_with_desired_and_min(1, 0),
4436 vec![
4437 member(MemberState::Free),
4438 member(MemberState::Free),
4439 member(MemberState::Spawning),
4440 ],
4441 ),
4442 (
4443 pool_with_desired_and_min(2, 0),
4444 vec![
4445 member(MemberState::Free),
4446 member(MemberState::Spawning),
4447 member(MemberState::Allocated),
4448 ],
4449 ),
4450 ];
4451 for (pool, members) in &cases {
4452 assert_eq!(
4453 pool.observed_phase_from(members),
4454 pre_lift(pool, members),
4455 "substrate primitive must match pre-lift reconciler chain for pool={:?} members={members:?}",
4456 pool.metadata.name,
4457 );
4458 }
4459 }
4460
4461 #[test]
4462 fn observed_phase_from_is_a_pure_projection() {
4463 // Purity pin: two consecutive calls on the same `(pool,
4464 // members)` return the same `PoolPhase` — no hidden state,
4465 // no per-tick clock read. Guards against a future refactor
4466 // that reaches for `Utc::now()` at the tombstone probe or
4467 // caches per-instance state.
4468 let p = pool_with_desired_and_min(3, 0);
4469 let members = vec![
4470 member(MemberState::Free),
4471 member(MemberState::Spawning),
4472 member(MemberState::Allocated),
4473 ];
4474 let a = p.observed_phase_from(&members);
4475 let b = p.observed_phase_from(&members);
4476 assert_eq!(a, b);
4477 assert_eq!(a, PoolPhase::ScalingUp);
4478 }
4479
4480 // ─── PoolStatus::observed_from substrate pins ────────────────────
4481 //
4482 // Pins the compound composer at fail-before-pass-after granularity:
4483 // `observed_from` did not exist pre-lift; the compiler cannot
4484 // resolve the name until the impl block above is in place, so a
4485 // rollback of the primitive breaks this whole test group. The
4486 // composer owns the 2-link `let phase = pool_phase_from_members
4487 // (&pool, &members); PoolStatus::observed_now(phase,
4488 // members.clone())` chain that both `controller_pool` status-
4489 // patch sites walked pre-lift; a regression that specialized
4490 // either component (a stray canonicalization at `observed_from`,
4491 // a swapped default at either component, a members-`Vec` clone
4492 // slipped into the composer boundary) would surface HERE rather
4493 // than as silent skew at either callsite.
4494
4495 #[test]
4496 fn pool_status_observed_from_delegates_through_phase_projection_and_observed_now() {
4497 // Delegation-shape pin: `observed_from(pool, members)` MUST
4498 // produce the SAME `PoolStatus` as the explicit 2-link
4499 // `PoolStatus::observed_now(pool.observed_phase_from(&members),
4500 // members)` chain at every slot other than `phase_since`
4501 // (which reads the wall clock at different instants and
4502 // diverges by scheduler jitter). Sweep the gate corners so a
4503 // regression at any phase arm surfaces here rather than as
4504 // silent skew at either controller_pool callsite.
4505 let cases: [(EphemeralPool, Vec<PoolMember>, PoolPhase); 4] = [
4506 (
4507 pool_with_desired_and_min(3, 0),
4508 vec![
4509 member(MemberState::Free),
4510 member(MemberState::Spawning),
4511 member(MemberState::Allocated),
4512 ],
4513 PoolPhase::ScalingUp,
4514 ),
4515 (
4516 pool_with_desired_and_min(2, 0),
4517 vec![
4518 member(MemberState::Free),
4519 member(MemberState::Spawning),
4520 member(MemberState::Allocated),
4521 ],
4522 PoolPhase::Steady,
4523 ),
4524 (
4525 pool_with_desired_and_min(1, 0),
4526 vec![
4527 member(MemberState::Free),
4528 member(MemberState::Free),
4529 member(MemberState::Spawning),
4530 ],
4531 PoolPhase::ScalingDown,
4532 ),
4533 (tombstoned_pool(), vec![], PoolPhase::Draining),
4534 ];
4535 for (pool, members, expected_phase) in cases {
4536 let via_compound = PoolStatus::observed_from(&pool, members.clone());
4537 let via_manual =
4538 PoolStatus::observed_now(pool.observed_phase_from(&members), members.clone());
4539 assert_eq!(via_compound.phase, expected_phase);
4540 assert_eq!(via_compound.phase, via_manual.phase);
4541 assert_eq!(via_compound.ready_count, via_manual.ready_count);
4542 assert_eq!(via_compound.allocated_count, via_manual.allocated_count);
4543 assert_eq!(via_compound.spawning_count, via_manual.spawning_count);
4544 assert_eq!(via_compound.returning_count, via_manual.returning_count);
4545 assert_eq!(via_compound.members.len(), via_manual.members.len());
4546 assert_eq!(via_compound.message, via_manual.message);
4547 assert_eq!(via_compound.conditions.len(), via_manual.conditions.len(),);
4548 }
4549 }
4550
4551 #[test]
4552 fn pool_status_observed_from_reads_wall_clock_into_phase_since() {
4553 // Wall-clock pin (inherited from `observed_now`): `phase_since`
4554 // MUST fall between `Utc::now()` reads bracketed around the
4555 // call. A regression that specialized the compound composer
4556 // with a stale timestamp constant would fail this bracket.
4557 let p = pool_with_desired_and_min(1, 0);
4558 let members = vec![member(MemberState::Free)];
4559 let before = chrono::Utc::now();
4560 let observed = PoolStatus::observed_from(&p, members);
4561 let after = chrono::Utc::now();
4562 let phase_since = observed
4563 .phase_since
4564 .expect("observed_from must stamp phase_since with the wall clock");
4565 assert!(
4566 phase_since >= before && phase_since <= after,
4567 "phase_since {phase_since} must fall in [{before}, {after}]"
4568 );
4569 }
4570
4571 #[test]
4572 fn pool_status_observed_from_derives_phase_from_pool_and_members() {
4573 // Phase-derivation pin: the compound composer MUST derive the
4574 // phase from the (pool, members) observations via
4575 // `EphemeralPool::observed_phase_from`, not via a caller-
4576 // supplied phase argument. A regression that reached for a
4577 // hard-coded default phase (`Steady` / `Initializing`) would
4578 // misreport the observed state at both controller_pool
4579 // callsites. Pinned across the five non-tombstone gate arms
4580 // so a per-arm regression surfaces per-corner.
4581 let cases: [(EphemeralPool, Vec<PoolMember>, PoolPhase); 5] = [
4582 (
4583 pool_with_desired_and_min(1, 0),
4584 vec![],
4585 PoolPhase::Initializing,
4586 ),
4587 (
4588 pool_with_desired_and_min(5, 2),
4589 vec![member(MemberState::Allocated)],
4590 PoolPhase::Degraded,
4591 ),
4592 (
4593 pool_with_desired_and_min(3, 0),
4594 vec![member(MemberState::Free), member(MemberState::Spawning)],
4595 PoolPhase::ScalingUp,
4596 ),
4597 (
4598 pool_with_desired_and_min(1, 0),
4599 vec![
4600 member(MemberState::Free),
4601 member(MemberState::Free),
4602 member(MemberState::Spawning),
4603 ],
4604 PoolPhase::ScalingDown,
4605 ),
4606 (
4607 pool_with_desired_and_min(2, 0),
4608 vec![
4609 member(MemberState::Free),
4610 member(MemberState::Spawning),
4611 member(MemberState::Allocated),
4612 ],
4613 PoolPhase::Steady,
4614 ),
4615 ];
4616 for (pool, members, expected) in cases {
4617 let observed = PoolStatus::observed_from(&pool, members);
4618 assert_eq!(
4619 observed.phase, expected,
4620 "observed_from must derive phase={expected:?} from pool + members",
4621 );
4622 }
4623 }
4624
4625 #[test]
4626 fn pool_status_observed_from_matches_pre_lift_two_link_chain_shape() {
4627 // Byte-identical parity with the pre-lift 2-link `let phase =
4628 // pool_phase_from_members(&pool, &members);
4629 // PoolStatus::observed_now(phase, members.clone())` chain both
4630 // hand-authored callsites walked. Both blocks read the wall
4631 // clock at DIFFERENT instants so the two `phase_since` stamps
4632 // CAN differ by scheduler jitter — bound the divergence at
4633 // 100ms scheduler jitter, matching the peer
4634 // `pool_status_observed_now_matches_pre_lift_utc_now_composition_shape`
4635 // tolerance on the sibling composer.
4636 let p = pool_with_desired_and_min(3, 0);
4637 let members = vec![
4638 member(MemberState::Free),
4639 member(MemberState::Spawning),
4640 member(MemberState::Allocated),
4641 ];
4642 let composed = PoolStatus::observed_from(&p, members.clone())
4643 .phase_since
4644 .expect("observed_from stamps phase_since");
4645 // Pre-lift block: compute phase separately, then hand it to
4646 // observed_now — exactly the shape the two callsites walked.
4647 let hand_authored = {
4648 let phase = p.observed_phase_from(&members);
4649 PoolStatus::observed_now(phase, members.clone())
4650 }
4651 .phase_since
4652 .expect("observed_now stamps phase_since");
4653 let delta = (hand_authored - composed).abs();
4654 assert!(
4655 delta <= chrono::Duration::milliseconds(100),
4656 "composed {composed} and hand-authored {hand_authored} must agree within 100ms scheduler jitter",
4657 );
4658 }
4659
4660 // ─── PoolMember::unallocated substrate pins ───────────────────────
4661 //
4662 // Pins the 4-slot `{ process_name, state, entered_state_at,
4663 // allocation_ref: None }` composer's fill at fail-before-pass-after
4664 // granularity: `unallocated` did not exist pre-lift; the compiler
4665 // cannot resolve the name until the impl block above is in place,
4666 // so a rollback of the primitive breaks this whole test group. The
4667 // primitive owns FIVE workspace-wide seed sites (one production
4668 // walk in `tatara-pool-reconciler::controller_pool::reconcile_inner`
4669 // and four test helpers across `pool.rs`, `pool_decide.rs`, and
4670 // `allocation_decide.rs`) so a regression that drifts any of the
4671 // four slots (a mistyped `allocation_ref: Some(<sentinel>)`, a
4672 // reversed positional order at the composer entry, an accidental
4673 // canonicalization of the `entered_state_at` anchor) surfaces here
4674 // rather than as silent operator-facing skew between the production
4675 // seed and the three test-suite helpers on the SAME `PoolMember`
4676 // shape.
4677
4678 #[test]
4679 fn pool_member_unallocated_fills_every_slot_verbatim() {
4680 // Positional-axis pin: the composer's four inputs land at the
4681 // four struct slots in declaration order. A regression that
4682 // swapped `process_name` and `entered_state_at` at the composer
4683 // entry (or that renamed the `allocation_ref` invariant slot to
4684 // a different `None`-preserving field) surfaces here.
4685 let anchor = crate::time::at_epoch_second(1_700_000_000);
4686 let m = PoolMember::unallocated("pool-x-0", MemberState::Free, anchor);
4687 assert_eq!(m.process_name, "pool-x-0");
4688 assert_eq!(m.state, MemberState::Free);
4689 assert_eq!(m.entered_state_at, anchor);
4690 assert!(m.allocation_ref.is_none());
4691 }
4692
4693 #[test]
4694 fn pool_member_unallocated_accepts_owned_string_and_str_at_the_same_signature() {
4695 // `impl Into<String>` axis pin: both the `&'static str` shape
4696 // (every test-helper site) and the `String` shape (produced by
4697 // `Process::owned_name_or_empty` at the production
4698 // `controller_pool::reconcile_inner` site) reach the same
4699 // composer entry without a per-caller conversion. A regression
4700 // that narrowed the signature to `&str` alone would break the
4701 // production site's `owned_name_or_empty` handoff; a regression
4702 // that narrowed to `String` alone would force every test helper
4703 // to `.into()` at the callsite. This pin fences both corners.
4704 let anchor = crate::time::at_epoch_second(0);
4705 let via_str = PoolMember::unallocated("pool-y-0", MemberState::Spawning, anchor);
4706 let owned: String = "pool-y-0".to_string();
4707 let via_string = PoolMember::unallocated(owned, MemberState::Spawning, anchor);
4708 assert_eq!(via_str.process_name, via_string.process_name);
4709 assert_eq!(via_str.state, via_string.state);
4710 assert_eq!(via_str.entered_state_at, via_string.entered_state_at);
4711 assert_eq!(via_str.allocation_ref, via_string.allocation_ref);
4712 }
4713
4714 #[test]
4715 fn pool_member_unallocated_matches_pre_lift_struct_literal_bytewise() {
4716 // Byte-shape parity pin: the composer output is structurally
4717 // equal to the pre-lift 4-slot struct literal every hand-
4718 // authored site stamped. Sweeps every `MemberState` variant so
4719 // a regression that special-cased one variant (e.g., pinned
4720 // `Allocated` to a bogus `Some(<placeholder>)` at the composer)
4721 // surfaces here rather than at the four downstream helpers'
4722 // callsites.
4723 let anchor = crate::time::at_epoch_second(1_700_000_000);
4724 for state in [
4725 MemberState::Free,
4726 MemberState::Allocated,
4727 MemberState::Spawning,
4728 MemberState::Returning,
4729 MemberState::Failed,
4730 ] {
4731 let via_primitive = PoolMember::unallocated("m", state, anchor);
4732 let hand_authored = PoolMember {
4733 process_name: "m".into(),
4734 state,
4735 entered_state_at: anchor,
4736 allocation_ref: None,
4737 };
4738 assert_eq!(via_primitive.process_name, hand_authored.process_name);
4739 assert_eq!(via_primitive.state, hand_authored.state);
4740 assert_eq!(
4741 via_primitive.entered_state_at,
4742 hand_authored.entered_state_at
4743 );
4744 assert_eq!(via_primitive.allocation_ref, hand_authored.allocation_ref);
4745 }
4746 }
4747
4748 #[test]
4749 fn pool_member_unallocated_preserves_caller_clock_anchor() {
4750 // Clock-injectability pin: the composer does NOT read wall
4751 // time on its own — every consumer supplies its own
4752 // `entered_state_at` anchor (the production site from
4753 // `Process::observed_phase_since`, the `pool_decide` helper
4754 // from `crate::time::seconds_ago`, the `allocation_decide` and
4755 // `pool.rs` helpers from `Utc::now` / the epoch anchor). A
4756 // regression that started stamping the composer's own
4757 // `Utc::now()` would silently reset every downstream anchor
4758 // and break the fanout tests' epoch-based expectations.
4759 let epoch = crate::time::at_epoch_second(0);
4760 let future = crate::time::at_epoch_second(2_000_000_000);
4761 let anchored_at_epoch = PoolMember::unallocated("a", MemberState::Free, epoch);
4762 let anchored_at_future = PoolMember::unallocated("b", MemberState::Free, future);
4763 assert_eq!(anchored_at_epoch.entered_state_at, epoch);
4764 assert_eq!(anchored_at_future.entered_state_at, future);
4765 assert_ne!(
4766 anchored_at_epoch.entered_state_at, anchored_at_future.entered_state_at,
4767 "composer must preserve the caller-supplied anchor verbatim",
4768 );
4769 }
4770
4771 // ─── EphemeralPool::has_name substrate pins ───────────────────────
4772 //
4773 // Pins the copy-form metadata-projection primitive on the
4774 // `metadata.name` axis's presence-and-equal corner — the
4775 // discriminant every `candidate_pools.iter().find(|p| ...)`
4776 // closure that resolves a pool from an owned-name handle
4777 // (`AllocationRef.name` / `AllocationDecision::Bind.pool.name`)
4778 // routes through. Sibling to the `_or_empty` family on the SAME
4779 // slot ([`EphemeralPool::name_or_empty`] +
4780 // [`EphemeralPool::owned_name_or_empty`]) — this primitive owns
4781 // the `None`-preserving corner the `_or_empty` family folds away.
4782 // Fail-before-pass-after granularity: `has_name` did not exist
4783 // pre-lift; the compiler cannot resolve the name until the impl
4784 // block above is in place, so a rollback of the primitive breaks
4785 // this whole module.
4786 #[test]
4787 fn has_name_returns_true_when_slot_is_populated_and_equal() {
4788 // Happy-path pin: the slot is set AND byte-identical to the
4789 // candidate. Both pre-lift `find` closures — `resolve_pool`'s
4790 // explicit-`pool_ref` half and `controller_allocation`'s TTL-
4791 // inheritance fallback — resolve their target pool exactly in
4792 // this corner, and the primitive returns `true` here to
4793 // authorize the resolution.
4794 let p = pool_named("attest-pool");
4795 assert!(p.has_name("attest-pool"));
4796 }
4797
4798 #[test]
4799 fn has_name_returns_false_when_slot_is_populated_and_different() {
4800 // Populated-slot inequality pin: the primitive returns `false`
4801 // for every candidate that is NOT byte-identical to the slot,
4802 // including strict subsequences (`"attest"` vs. `"attest-pool"`),
4803 // strict superstrings (`"attest-pool-2"` vs. `"attest-pool"`),
4804 // and case-differ variants. This is the load-bearing property
4805 // that lets `find(|p| p.has_name(&candidate))` reject
4806 // non-matching pools rather than aliasing them together.
4807 let p = pool_named("attest-pool");
4808 assert!(!p.has_name("other-pool"));
4809 assert!(!p.has_name("attest"));
4810 assert!(!p.has_name("attest-pool-2"));
4811 assert!(!p.has_name("ATTEST-POOL"));
4812 }
4813
4814 #[test]
4815 fn has_name_returns_false_when_slot_is_none_even_against_empty_candidate() {
4816 // The `None`-preserving discipline pin: an unset `metadata.name`
4817 // slot returns `false` even when the candidate is the empty
4818 // string. Distinguishes `has_name` from a naïve substitution
4819 // through the sibling `name_or_empty` primitive, which would
4820 // fold both `None` and `Some("")` to `""` and silently promote
4821 // an unnamed pool with an empty candidate into a spurious
4822 // match at the resolver's `find` closure. Byte-identical to
4823 // what the pre-lift `.as_deref() == Some(<candidate>)` chain
4824 // produced (`None == Some("")` is `false`), which is what
4825 // both consumer sites relied on.
4826 let p = pool_unnamed();
4827 assert!(p.metadata.name.is_none(), "fixture invariant");
4828 assert!(!p.has_name(""));
4829 assert!(!p.has_name("attest-pool"));
4830 }
4831
4832 #[test]
4833 fn has_name_returns_true_only_when_populated_slot_and_candidate_are_both_empty() {
4834 // Populated-empty-slot corner pin: `Some(String::new())` is a
4835 // populated slot with an empty payload. `has_name("")` returns
4836 // `true` here (byte-identical `""` on both sides), while
4837 // `has_name("<anything else>")` returns `false`. This is the
4838 // corner where `has_name` DIVERGES from `name_or_empty`
4839 // observably: the `_or_empty` family folds this corner into
4840 // the same bucket as `None`, but `has_name` keeps the
4841 // presence bit visible — `Some("") == Some("")` is `true`
4842 // while `None == Some("")` is `false`.
4843 let mut p = pool_named("scratch");
4844 p.metadata.name = Some(String::new());
4845 assert!(p.has_name(""));
4846 assert!(!p.has_name("attest-pool"));
4847 }
4848
4849 #[test]
4850 fn has_name_matches_pre_lift_chain_verbatim_across_full_corner_set() {
4851 // Byte-identical parity pin: the primitive returns the same
4852 // `bool` as the pre-lift `.metadata.name.as_deref() == Some
4853 // (candidate)` chain across the FULL cross product of
4854 // (slot ∈ {None, Some("attest-pool"), Some("")}) × (candidate
4855 // ∈ {"attest-pool", "", "other"}). A regression that inserted
4856 // a normalization step at the primitive the pre-lift chain
4857 // does NOT apply — or vice versa — surfaces here rather than
4858 // as silent drift between the two `find` closures the primitive
4859 // owns.
4860 let slots: [Option<String>; 3] =
4861 [None, Some(String::from("attest-pool")), Some(String::new())];
4862 let candidates: [&str; 3] = ["attest-pool", "", "other"];
4863 for slot in slots {
4864 let mut p = pool_named("scratch");
4865 p.metadata.name = slot.clone();
4866 for candidate in candidates {
4867 let pre_lift = p.metadata.name.as_deref() == Some(candidate);
4868 assert_eq!(
4869 p.has_name(candidate),
4870 pre_lift,
4871 "slot = {slot:?}, candidate = {candidate:?}"
4872 );
4873 }
4874 }
4875 }
4876
4877 #[test]
4878 fn has_name_diverges_from_name_or_empty_on_the_missing_slot_corner() {
4879 // Cross-primitive discipline pin: `has_name("")` and
4880 // `name_or_empty() == ""` MUST disagree on the `None`-slot
4881 // corner. `name_or_empty` returns `""` (its load-bearing
4882 // sentinel), so a naïve `name_or_empty() == ""` probe would
4883 // return `true` here — aliasing every unnamed pool to the
4884 // empty-candidate bucket at the resolver. `has_name`
4885 // preserves `Option::as_deref() == Some(_)`'s `None ⇒ false`
4886 // semantics, so it returns `false` and rejects the spurious
4887 // match. This test fences the WHOLE reason `has_name` exists
4888 // as a distinct primitive from the `_or_empty` family: a
4889 // future refactor that collapsed `has_name` into
4890 // `name_or_empty() == candidate` would break this pin and
4891 // silently regress the resolver's byte-comparison honesty.
4892 let p = pool_unnamed();
4893 assert_eq!(p.name_or_empty(), "");
4894 assert!(!p.has_name(""));
4895 }
4896
4897 #[test]
4898 fn has_name_is_a_pure_projection() {
4899 // Consecutive calls with the same candidate return the same
4900 // `bool` — no cached state, no mutation on the `EphemeralPool`
4901 // between calls. Guards against a future refactor that plants
4902 // a cache field on `EphemeralPool` and drifts one caller from
4903 // another silently.
4904 let p = pool_named("router-pool");
4905 assert_eq!(p.has_name("router-pool"), p.has_name("router-pool"));
4906 assert_eq!(p.has_name("other"), p.has_name("other"));
4907 assert!(p.has_name("router-pool"));
4908 assert!(!p.has_name("other"));
4909 }
4910
4911 // ─── PoolSpec::free_ttl_duration substrate pins ─────────────────
4912 //
4913 // The `humantime::parse_duration(&<field>).ok()` shape rides
4914 // through TWO peer inherent methods on peer spec types post-lift:
4915 // [`crate::lifetime::EphemeralLifetime::ttl_duration`] on the
4916 // `spec.lifetime.ephemeral.ttl` axis + [`PoolSpec::free_ttl_
4917 // duration`] on the `pool.spec.free_ttl` axis. These pins bind the
4918 // pool-spec-side primitive at fail-before-pass-after granularity
4919 // so a regression that drifts either surface (a per-fleet minimum
4920 // floor added at only one primitive, a canonical unit-normalization
4921 // pass, a warn-log on unparseable strings) fails here rather than
4922 // as silent operator-facing skew between the pool stale-free
4923 // bucket loop in `tatara-pool-reconciler::pool_decide::decide_pool`
4924 // and the ephemeral TTL-expiry gate in
4925 // `tatara-process::lifetime_clock::evaluate`.
4926
4927 fn pool_spec_with_free_ttl(free_ttl: &str) -> PoolSpec {
4928 PoolSpec {
4929 free_ttl: free_ttl.into(),
4930 ..pool_spec()
4931 }
4932 }
4933
4934 #[test]
4935 fn pool_spec_free_ttl_duration_parseable_humantime_projects_to_some() {
4936 for (ttl, expected_secs) in [
4937 ("30s", 30u64),
4938 ("5m", 300),
4939 ("1h", 3600),
4940 ("24h", 86_400),
4941 ("1d", 86_400),
4942 ] {
4943 let spec = pool_spec_with_free_ttl(ttl);
4944 assert_eq!(
4945 spec.free_ttl_duration(),
4946 Some(std::time::Duration::from_secs(expected_secs)),
4947 "free_ttl_duration drift for {ttl:?}",
4948 );
4949 }
4950 }
4951
4952 #[test]
4953 fn pool_spec_free_ttl_duration_unparseable_returns_none() {
4954 // A typo (`"1our"`), an unsupported unit (`"1w"` — humantime
4955 // supports `w`, but `"forever"` doesn't), a non-humantime
4956 // literal that reached the field via API-server acceptance
4957 // ALL collapse to `None`. The `pool_decide::decide_pool`
4958 // caller collapses the corner via `.unwrap_or_default()`,
4959 // yielding `Duration::ZERO` — byte-identical to the pre-lift
4960 // hand-authored `humantime::parse_duration(&spec.free_ttl)
4961 // .unwrap_or_default()` semantics.
4962 for bad in ["", "1our", "forever", "not-a-duration", "1", "-1s"] {
4963 let spec = pool_spec_with_free_ttl(bad);
4964 assert_eq!(
4965 spec.free_ttl_duration(),
4966 None,
4967 "free_ttl_duration should be None for {bad:?}",
4968 );
4969 }
4970 }
4971
4972 #[test]
4973 fn pool_spec_free_ttl_duration_zero_seconds_returns_some_zero() {
4974 // `"0s"` is a parseable-but-zero humantime literal — the
4975 // primitive returns `Some(Duration::ZERO)`, distinguishable
4976 // from the parse-failure `None` corner. Downstream consumers
4977 // that gate on `!free_ttl.is_zero()` collapse this back
4978 // together with the `None`-via-`unwrap_or_default()` corner,
4979 // but the primitive itself keeps the two shapes distinct so
4980 // a future consumer needing that distinction can reach for
4981 // it without a re-parse.
4982 let spec = pool_spec_with_free_ttl("0s");
4983 assert_eq!(
4984 spec.free_ttl_duration(),
4985 Some(std::time::Duration::ZERO),
4986 "0s should project to Some(Duration::ZERO), not None",
4987 );
4988 }
4989
4990 #[test]
4991 fn pool_spec_free_ttl_duration_default_free_ttl_matches_24h() {
4992 // The default `free_ttl` is `"24h"` (via [`default_free_ttl`]).
4993 // The primitive on a `PoolSpec` carrying the default must
4994 // agree with a manually-parsed `"24h"` — a future
4995 // `default_free_ttl` change (a shorter recycling window, a
4996 // per-fleet override) reaches BOTH surfaces at once (this
4997 // pin + the `default_free_ttl` fn) without silent skew.
4998 let spec = pool_spec_with_free_ttl(&default_free_ttl());
4999 assert_eq!(
5000 spec.free_ttl_duration(),
5001 Some(std::time::Duration::from_secs(24 * 3600)),
5002 );
5003 }
5004
5005 #[test]
5006 fn pool_spec_free_ttl_duration_matches_pre_lift_hand_authored_chain_bytewise() {
5007 // Byte-shape parity with the pre-lift hand-authored chain the
5008 // `pool_decide::decide_pool` stale-free bucket loop restated
5009 // (`humantime::parse_duration(&spec.free_ttl).ok()` — the
5010 // `.ok()` tail and the caller's `.unwrap_or_default()` compose
5011 // to the same `Duration::ZERO`-on-failure semantics). Sweeps
5012 // every callsite corner the pool reconciler plausibly
5013 // encounters: the default `"24h"` free-recycling window, a
5014 // short-window test override (`"10s"`), a parse-failure typo,
5015 // an empty string.
5016 for ttl in ["24h", "10s", "1our", ""] {
5017 let spec = pool_spec_with_free_ttl(ttl);
5018 let via_primitive = spec.free_ttl_duration();
5019 let hand_authored = humantime::parse_duration(&spec.free_ttl).ok();
5020 assert_eq!(
5021 via_primitive, hand_authored,
5022 "free_ttl_duration must be byte-identical to `humantime::\
5023 parse_duration(&spec.free_ttl).ok()` for {ttl:?}",
5024 );
5025 }
5026 }
5027
5028 #[test]
5029 fn pool_spec_free_ttl_duration_matches_peer_ephemeral_lifetime_ttl_duration_shape() {
5030 // Return-shape parity with the peer primitive
5031 // [`crate::lifetime::EphemeralLifetime::ttl_duration`]: given
5032 // the SAME humantime string on both peer fields (the pool
5033 // `free_ttl` slot AND the ephemeral `ttl` slot), the two
5034 // primitives return byte-identical `Option<Duration>` values.
5035 // A regression that inserted a per-primitive normalization
5036 // step at only one surface — a per-fleet minimum floor, a
5037 // canonical unit-normalization pass — surfaces here rather
5038 // than as silent operator-facing skew between the pool
5039 // stale-free bucket loop and the ephemeral TTL-expiry gate
5040 // on the SAME humantime literal.
5041 for ttl in ["30s", "1h", "24h", "1our", ""] {
5042 let pool_spec = pool_spec_with_free_ttl(ttl);
5043 let eph = crate::lifetime::EphemeralLifetime {
5044 ttl: ttl.into(),
5045 ..Default::default()
5046 };
5047 assert_eq!(
5048 pool_spec.free_ttl_duration(),
5049 eph.ttl_duration(),
5050 "peer-primitive shape drift for {ttl:?}",
5051 );
5052 }
5053 }
5054
5055 // ── PoolSpec::with_template substrate pins ──────────────────────
5056 //
5057 // The 11-slot `PoolSpec { desired_size: <N>, min_size: 0, max_size:
5058 // 0, return_policy: ReturnPolicy::Replace, selector: <PoolSelector
5059 // ::default() or override>, template: <EphemeralSpec>, free_ttl:
5060 // "24h".into(), max_allocation_ttl: "4h".into(), desired: 0,
5061 // replacement_policy: Default::default(), stable_name_claim: false
5062 // }` struct-literal was open-coded verbatim at EIGHT hand-authored
5063 // callsites across two crates before this primitive closed it.
5064 // These pins bind the composed shape at fail-before-pass-after
5065 // granularity so a regression that drifted the wire-published
5066 // default at only one slot — a shorter `default_free_ttl`, a
5067 // widened `ReturnPolicy` default, a promoted `stable_name_claim`
5068 // seed — surfaces HERE rather than as silent operator-visible drift
5069 // across every fixture that keys assertions on the shape.
5070 fn hand_authored_pre_lift_with_template() -> PoolSpec {
5071 PoolSpec {
5072 desired_size: 0,
5073 min_size: 0,
5074 max_size: 0,
5075 return_policy: ReturnPolicy::Replace,
5076 selector: PoolSelector::default(),
5077 template: empty_template(),
5078 free_ttl: "24h".into(),
5079 max_allocation_ttl: "4h".into(),
5080 desired: 0,
5081 replacement_policy: ReplacementPolicy::default(),
5082 stable_name_claim: false,
5083 }
5084 }
5085
5086 #[test]
5087 fn with_template_stamps_caller_supplied_template_verbatim() {
5088 // The caller-supplied slot is the ONE the substrate does not
5089 // default. A regression that reshaped the primitive's
5090 // pass-through — a hidden re-encode through
5091 // `serde_json::to_value` and back, a per-primitive
5092 // normalization that flipped a defaulted-inner slot — would
5093 // surface HERE rather than at every downstream seed whose
5094 // assertions key on the template shape.
5095 let t = empty_template();
5096 let s = PoolSpec::with_template(t.clone());
5097 assert_eq!(
5098 serde_json::to_value(&s.template).unwrap(),
5099 serde_json::to_value(&t).unwrap(),
5100 );
5101 }
5102
5103 #[test]
5104 fn with_template_defaulted_slots_ride_wire_schema_defaults() {
5105 // Pins the sibling-default correspondence the doc-comment
5106 // names — every non-template slot rides its own
5107 // `#[serde(default = "…")]` value from the `pub struct
5108 // PoolSpec` schema above. A regression that promoted any
5109 // defaulted slot to a non-default (a shorter
5110 // `default_free_ttl`, a widened `ReturnPolicy` default, a
5111 // `stable_name_claim: true` seed) would move the baseline
5112 // HERE rather than at every downstream fixture.
5113 let s = PoolSpec::with_template(empty_template());
5114 assert_eq!(s.desired_size, 0);
5115 assert_eq!(s.min_size, 0);
5116 assert_eq!(s.max_size, 0);
5117 assert_eq!(s.return_policy, ReturnPolicy::default());
5118 assert_eq!(
5119 serde_json::to_value(&s.selector).unwrap(),
5120 serde_json::to_value(PoolSelector::default()).unwrap(),
5121 );
5122 assert_eq!(s.free_ttl, default_free_ttl());
5123 assert_eq!(s.max_allocation_ttl, default_max_allocation_ttl());
5124 assert_eq!(s.desired, 0);
5125 assert_eq!(s.replacement_policy, ReplacementPolicy::default());
5126 assert!(!s.stable_name_claim);
5127 }
5128
5129 #[test]
5130 fn with_template_matches_hand_authored_pre_lift_bytewise() {
5131 // Byte-identical parity pin between the substrate primitive
5132 // and the pre-lift 11-slot struct-literal that recurred at
5133 // eight hand-authored sites (compared with `desired_size:
5134 // 0` to match the primitive's baseline — the five hand-
5135 // authored `desired_size: 1` sites compose the baseline via
5136 // struct-update and the pin below binds THAT axis
5137 // separately). Compares via `serde_json` value equality —
5138 // `PoolSpec` does not derive `PartialEq` (the typed fields
5139 // it composes over do not uniformly derive it), so a
5140 // serialize round-trip is the shape-equality currency the
5141 // pin family already uses.
5142 let composed = PoolSpec::with_template(empty_template());
5143 let hand = hand_authored_pre_lift_with_template();
5144 assert_eq!(
5145 serde_json::to_value(&composed).unwrap(),
5146 serde_json::to_value(&hand).unwrap(),
5147 );
5148 }
5149
5150 #[test]
5151 fn with_template_supports_struct_update_override_at_each_pre_lift_axis() {
5152 // Sweeps every override axis the eight pre-lift seeds
5153 // exercised via struct-update syntax:
5154 // * `desired_size: 1` — six sites (the majority of pre-lift
5155 // fixtures use a single-slot pool).
5156 // * `selector: <custom>` — two sites (router.rs +
5157 // allocation_decide.rs).
5158 // * `desired: N` + `replacement_policy: <policy>` — one
5159 // site (desired.rs's desired-count-loop fixture).
5160 // * `desired_size: N, min_size: N, max_size: N` — one site
5161 // (pool_decide.rs's pure-decision fixture).
5162 // A regression that broke the struct-update path (e.g. a
5163 // `#[non_exhaustive]` attribute added to `PoolSpec` that
5164 // would refuse struct-update syntax across crate boundaries)
5165 // surfaces at compile time HERE rather than as an eight-site
5166 // downstream break.
5167 let base = PoolSpec::with_template(empty_template());
5168 let size_1 = PoolSpec {
5169 desired_size: 1,
5170 ..PoolSpec::with_template(empty_template())
5171 };
5172 assert_eq!(base.desired_size, 0);
5173 assert_eq!(size_1.desired_size, 1);
5174 // Every other slot rides the base composition.
5175 assert_eq!(size_1.free_ttl, base.free_ttl);
5176 assert_eq!(size_1.max_allocation_ttl, base.max_allocation_ttl);
5177
5178 let custom_selector = PoolSelector::default();
5179 let with_selector = PoolSpec {
5180 desired_size: 1,
5181 selector: custom_selector,
5182 ..PoolSpec::with_template(empty_template())
5183 };
5184 assert_eq!(with_selector.desired_size, 1);
5185 assert_eq!(with_selector.free_ttl, base.free_ttl);
5186
5187 let with_desired = PoolSpec {
5188 desired: 5,
5189 replacement_policy: ReplacementPolicy::HoldFailed,
5190 ..PoolSpec::with_template(empty_template())
5191 };
5192 assert_eq!(with_desired.desired, 5);
5193 assert_eq!(
5194 with_desired.replacement_policy,
5195 ReplacementPolicy::HoldFailed
5196 );
5197 assert_eq!(with_desired.desired_size, 0);
5198
5199 let with_sizes = PoolSpec {
5200 desired_size: 3,
5201 min_size: 1,
5202 max_size: 5,
5203 ..PoolSpec::with_template(empty_template())
5204 };
5205 assert_eq!(with_sizes.desired_size, 3);
5206 assert_eq!(with_sizes.min_size, 1);
5207 assert_eq!(with_sizes.max_size, 5);
5208 assert_eq!(with_sizes.replacement_policy, base.replacement_policy);
5209 }
5210
5211 #[test]
5212 fn with_template_is_call_time_construction_not_a_shared_singleton() {
5213 // Two independent calls produce structurally-equal but
5214 // distinct values — pins that the primitive is a plain
5215 // constructor rather than a `lazy_static` clone whose in-
5216 // place mutation at one consumer would silently mutate the
5217 // shape at every other consumer. Mirrors the sibling
5218 // `gate_compute_defaults_is_call_time_construction_not_a_
5219 // shared_singleton` pin on `ProcessSpec::gate_compute_defaults`.
5220 let a = PoolSpec::with_template(empty_template());
5221 let b = PoolSpec::with_template(empty_template());
5222 assert_eq!(
5223 serde_json::to_value(&a).unwrap(),
5224 serde_json::to_value(&b).unwrap(),
5225 );
5226 assert!(!std::ptr::eq(&a, &b));
5227 }
5228
5229 #[test]
5230 fn with_template_free_ttl_composes_with_free_ttl_duration_at_default_window() {
5231 // The primitive's `free_ttl` slot rides `default_free_ttl()`;
5232 // the sibling `free_ttl_duration` primitive parses that
5233 // literal into the same 24h `Duration` every pre-lift
5234 // reconciler-side seed produced. Pins the round-trip so a
5235 // regression that shifted `default_free_ttl` without
5236 // updating this baseline (or vice versa) surfaces HERE
5237 // rather than as silent skew between the composer and the
5238 // ttl-parse gate that consumes it.
5239 let s = PoolSpec::with_template(empty_template());
5240 assert_eq!(
5241 s.free_ttl_duration(),
5242 Some(std::time::Duration::from_secs(24 * 3600)),
5243 );
5244 }
5245
5246 // ─── EphemeralPool::new_in substrate pins ─────────────────────────
5247 //
5248 // The pre-lift 2-line `let mut p = EphemeralPool::new(<name>,
5249 // <spec>); p.meta_mut().namespace = Some(<ns>.into());` chain
5250 // recurred at FOUR workspace-wide fixture sites in
5251 // `tatara-pool-reconciler` past the ★★ PRIME-DIRECTIVE ≥ 2
5252 // threshold. Post-lift the ONE substrate composer stamps a
5253 // namespaced `EphemeralPool` from `(name, ns, spec)` in one call.
5254 // Fail-before-pass-after granularity: `new_in` did not exist pre-
5255 // lift; the compiler cannot resolve the name until the impl block
5256 // above is in place, so a rollback of the primitive breaks this
5257 // whole pin block.
5258
5259 #[test]
5260 fn new_in_stamps_metadata_name_from_the_name_slot() {
5261 // `name` slot → `metadata.name` projection pin. Guards against
5262 // a regression that dropped the `name` slot into a `generate_
5263 // name` slot, an `annotations` seed, or any downstream slot the
5264 // kube-derived [`Self::new`] does not populate at
5265 // `metadata.name` verbatim.
5266 let s = pool_spec();
5267 let p = EphemeralPool::new_in("attest-pool", "pools", s);
5268 assert_eq!(p.metadata.name.as_deref(), Some("attest-pool"));
5269 }
5270
5271 #[test]
5272 fn new_in_stamps_metadata_namespace_from_the_ns_slot() {
5273 // `ns` slot → `metadata.namespace` projection pin. Guards
5274 // against a regression that dropped the `ns` slot into a
5275 // `labels` seed, an unrelated annotation, or that stamped
5276 // `namespace = None` even after a caller-supplied value.
5277 let s = pool_spec();
5278 let p = EphemeralPool::new_in("attest-pool", "pools", s);
5279 assert_eq!(p.metadata.namespace.as_deref(), Some("pools"));
5280 }
5281
5282 #[test]
5283 fn new_in_stamps_spec_from_the_spec_slot_verbatim() {
5284 // `spec` slot → `spec` projection pin. A regression that
5285 // silently normalized the caller-supplied spec inside the
5286 // composer (a defaulted-slot reset, a per-fleet override) would
5287 // diverge from the byte-identical pass-through the pre-lift
5288 // 2-line chain produced.
5289 let mut s = pool_spec();
5290 s.desired_size = 7;
5291 let p = EphemeralPool::new_in("attest-pool", "pools", s.clone());
5292 assert_eq!(p.spec.desired_size, s.desired_size);
5293 assert_eq!(p.spec.min_size, s.min_size);
5294 assert_eq!(p.spec.max_size, s.max_size);
5295 }
5296
5297 #[test]
5298 fn new_in_accepts_both_owned_and_borrowed_namespace_slot() {
5299 // The `impl Into<String>` ergonomic contract round-trips
5300 // through both `&'static str` (majority pre-lift caller shape)
5301 // AND owned `String` at the SAME signature. Guards against a
5302 // regression that narrowed the slot to `&str` only or that
5303 // silently double-`.into()`d an already-owned String.
5304 let s = pool_spec();
5305 let via_str = EphemeralPool::new_in("attest-pool", "pools", s.clone());
5306 let via_string = EphemeralPool::new_in("attest-pool", String::from("pools"), s.clone());
5307 assert_eq!(via_str.metadata.namespace, via_string.metadata.namespace);
5308 }
5309
5310 #[test]
5311 fn new_in_matches_pre_lift_construct_then_set_namespace_bytewise() {
5312 // Byte-shape parity witness against the pre-lift 2-line chain
5313 // across the two representative namespace shapes the collapsed
5314 // sites used (`"ephemeral-pools"` at `router::pool`, `"pools"`
5315 // at `pool_decide::pool` + `desired::pool` +
5316 // `allocation_decide::pool`). A regression that shifted the
5317 // composer's output would diverge from the pre-lift literal
5318 // HERE rather than at every downstream fixture's downstream
5319 // assertion.
5320 for ns in ["ephemeral-pools", "pools"] {
5321 let via_primitive = EphemeralPool::new_in("attest-pool", ns, pool_spec());
5322 let mut hand_authored = EphemeralPool::new("attest-pool", pool_spec());
5323 hand_authored.metadata.namespace = Some(ns.into());
5324 assert_eq!(via_primitive.metadata.name, hand_authored.metadata.name);
5325 assert_eq!(
5326 via_primitive.metadata.namespace,
5327 hand_authored.metadata.namespace,
5328 );
5329 }
5330 }
5331
5332 #[test]
5333 fn new_in_defaults_other_metadata_slots_at_kube_derived_new() {
5334 // The composer forwards to the kube-derived [`Self::new`] for
5335 // every non-namespace metadata slot. A regression that stamped
5336 // finalizers, owner_references, labels, or annotations inside
5337 // the composer's body — inheriting the pre-lift chain's
5338 // undocumented emptiness at those slots — would surface here.
5339 let p = EphemeralPool::new_in("attest-pool", "pools", pool_spec());
5340 assert!(p.metadata.finalizers.is_none());
5341 assert!(p.metadata.owner_references.is_none());
5342 assert!(p.metadata.labels.is_none());
5343 assert!(p.metadata.annotations.is_none());
5344 }
5345}