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