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