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